Initial commit
This commit is contained in:
115
skills/api-design-standards/templates/error-handler.py
Normal file
115
skills/api-design-standards/templates/error-handler.py
Normal file
@@ -0,0 +1,115 @@
|
||||
# Grey Haven Studio - Error Handler Template
|
||||
# Add this to app/core/exceptions.py
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
"""Handle HTTPException with standard error format."""
|
||||
logger.warning(f"HTTP exception: {exc.status_code} - {exc.detail}")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": exc.detail,
|
||||
"status_code": exc.status_code,
|
||||
},
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
"""Handle Pydantic validation errors."""
|
||||
errors = []
|
||||
for error in exc.errors():
|
||||
field_path = ".".join(str(loc) for loc in error["loc"] if loc != "body")
|
||||
errors.append(
|
||||
{
|
||||
"field": field_path if field_path else None,
|
||||
"message": error["msg"],
|
||||
"code": error["type"],
|
||||
}
|
||||
)
|
||||
|
||||
logger.warning(f"Validation error: {errors}")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={
|
||||
"error": "Validation error",
|
||||
"detail": errors,
|
||||
"status_code": 422,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def integrity_error_handler(
|
||||
request: Request, exc: IntegrityError
|
||||
) -> JSONResponse:
|
||||
"""Handle database integrity errors."""
|
||||
logger.error(f"Integrity error: {exc}")
|
||||
|
||||
error_detail = "A resource with this unique value already exists"
|
||||
if "foreign key" in str(exc).lower():
|
||||
error_detail = "Referenced resource does not exist"
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={
|
||||
"error": error_detail,
|
||||
"status_code": 409,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def operational_error_handler(
|
||||
request: Request, exc: OperationalError
|
||||
) -> JSONResponse:
|
||||
"""Handle database operational errors."""
|
||||
logger.error(f"Database operational error: {exc}")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
content={
|
||||
"error": "Service temporarily unavailable",
|
||||
"detail": "Database connection error. Please try again later.",
|
||||
"status_code": 503,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Catch-all handler for unexpected exceptions."""
|
||||
logger.exception(f"Unhandled exception: {exc}")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"error": "Internal server error",
|
||||
"status_code": 500,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Register in main.py:
|
||||
# from app.core.exceptions import (
|
||||
# http_exception_handler,
|
||||
# validation_exception_handler,
|
||||
# integrity_error_handler,
|
||||
# operational_error_handler,
|
||||
# generic_exception_handler,
|
||||
# )
|
||||
#
|
||||
# app.add_exception_handler(HTTPException, http_exception_handler)
|
||||
# app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||
# app.add_exception_handler(IntegrityError, integrity_error_handler)
|
||||
# app.add_exception_handler(OperationalError, operational_error_handler)
|
||||
# app.add_exception_handler(Exception, generic_exception_handler)
|
||||
130
skills/api-design-standards/templates/fastapi-crud-endpoint.py
Normal file
130
skills/api-design-standards/templates/fastapi-crud-endpoint.py
Normal file
@@ -0,0 +1,130 @@
|
||||
# Grey Haven Studio - FastAPI CRUD Endpoint Template
|
||||
# Copy this template for new resource endpoints
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.repositories.resource_repository import ResourceRepository # TODO: Update import
|
||||
from app.schemas.resource import ResourceCreate, ResourceRead, ResourceUpdate, PaginatedResponse # TODO: Update import
|
||||
|
||||
# TODO: Update prefix, tags, and model name
|
||||
router = APIRouter(prefix="/api/v1/resources", tags=["resources"])
|
||||
|
||||
|
||||
@router.get("", response_model=PaginatedResponse[ResourceRead], status_code=status.HTTP_200_OK)
|
||||
async def list_resources(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
# TODO: Add filter parameters as needed
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> PaginatedResponse[ResourceRead]:
|
||||
"""
|
||||
List all resources in the current tenant with pagination.
|
||||
|
||||
- **skip**: Number of records to skip (pagination offset)
|
||||
- **limit**: Maximum number of records to return (max 100)
|
||||
- **Returns**: Paginated list of resources
|
||||
"""
|
||||
repository = ResourceRepository(db, tenant_id=current_user.tenant_id)
|
||||
|
||||
# TODO: Build filter criteria if needed
|
||||
filters = {}
|
||||
|
||||
resources = await repository.list(filters=filters, skip=skip, limit=limit)
|
||||
total = await repository.count(filters=filters)
|
||||
|
||||
return PaginatedResponse(
|
||||
items=resources,
|
||||
total=total,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
has_more=(skip + limit) < total,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{resource_id}", response_model=ResourceRead, status_code=status.HTTP_200_OK)
|
||||
async def get_resource(
|
||||
resource_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> ResourceRead:
|
||||
"""Get a single resource by ID (tenant-isolated)."""
|
||||
repository = ResourceRepository(db, tenant_id=current_user.tenant_id)
|
||||
resource = await repository.get_by_id(resource_id)
|
||||
if not resource:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Resource with ID {resource_id} not found",
|
||||
)
|
||||
return resource
|
||||
|
||||
|
||||
@router.post("", response_model=ResourceRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_resource(
|
||||
resource_data: ResourceCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> ResourceRead:
|
||||
"""
|
||||
Create a new resource in the current tenant.
|
||||
|
||||
**Errors**:
|
||||
- 409 Conflict: Duplicate resource
|
||||
- 422 Validation Error: Invalid data
|
||||
"""
|
||||
repository = ResourceRepository(db, tenant_id=current_user.tenant_id)
|
||||
|
||||
try:
|
||||
resource = await repository.create(resource_data)
|
||||
return resource
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Resource with this identifier already exists",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{resource_id}", response_model=ResourceRead, status_code=status.HTTP_200_OK)
|
||||
async def update_resource(
|
||||
resource_id: str,
|
||||
resource_data: ResourceUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> ResourceRead:
|
||||
"""
|
||||
Update an existing resource (tenant-isolated).
|
||||
|
||||
All fields are optional - only provided fields will be updated.
|
||||
"""
|
||||
repository = ResourceRepository(db, tenant_id=current_user.tenant_id)
|
||||
resource = await repository.update(resource_id, resource_data)
|
||||
if not resource:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Resource with ID {resource_id} not found",
|
||||
)
|
||||
return resource
|
||||
|
||||
|
||||
@router.delete("/{resource_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_resource(
|
||||
resource_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> None:
|
||||
"""
|
||||
Soft-delete a resource (tenant-isolated).
|
||||
|
||||
Returns 204 No Content on success (no response body).
|
||||
"""
|
||||
repository = ResourceRepository(db, tenant_id=current_user.tenant_id)
|
||||
success = await repository.delete(resource_id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Resource with ID {resource_id} not found",
|
||||
)
|
||||
61
skills/api-design-standards/templates/pydantic-schemas.py
Normal file
61
skills/api-design-standards/templates/pydantic-schemas.py
Normal file
@@ -0,0 +1,61 @@
|
||||
# Grey Haven Studio - Pydantic Schema Template
|
||||
# Copy this template for new resource schemas
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, ConfigDict, field_validator
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# TODO: Update model name
|
||||
class ResourceBase(BaseModel):
|
||||
"""Shared fields for Resource schemas."""
|
||||
|
||||
# TODO: Add your base fields here
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ResourceCreate(ResourceBase):
|
||||
"""Schema for creating a new resource."""
|
||||
|
||||
# TODO: Add creation-specific fields
|
||||
# Example: password, external_id, etc.
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
"""Add custom validation if needed."""
|
||||
if not v.strip():
|
||||
raise ValueError("Name cannot be empty or whitespace")
|
||||
return v.strip()
|
||||
|
||||
|
||||
class ResourceUpdate(BaseModel):
|
||||
"""Schema for updating an existing resource (all fields optional)."""
|
||||
|
||||
# TODO: Add updateable fields (all optional)
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class ResourceRead(ResourceBase):
|
||||
"""Schema for reading resource data (public fields only)."""
|
||||
|
||||
id: str
|
||||
tenant_id: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PaginatedResponse[T](BaseModel):
|
||||
"""Generic paginated response."""
|
||||
|
||||
items: list[T]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
has_more: bool
|
||||
82
skills/api-design-standards/templates/rate-limiter.py
Normal file
82
skills/api-design-standards/templates/rate-limiter.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# Grey Haven Studio - Rate Limiter Template
|
||||
# Add this to app/core/rate_limit.py
|
||||
|
||||
from fastapi import Request, HTTPException, status
|
||||
from upstash_redis import Redis
|
||||
import os
|
||||
|
||||
# Doppler provides REDIS_URL
|
||||
redis = Redis.from_url(os.getenv("REDIS_URL"))
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""
|
||||
Rate limiter using Upstash Redis.
|
||||
|
||||
Usage:
|
||||
rate_limit_strict = RateLimiter(max_requests=10, window=60)
|
||||
rate_limit_normal = RateLimiter(max_requests=100, window=60)
|
||||
|
||||
@router.post("", dependencies=[Depends(rate_limit_strict)])
|
||||
async def create_resource():
|
||||
pass
|
||||
"""
|
||||
|
||||
def __init__(self, max_requests: int = 100, window: int = 60):
|
||||
"""
|
||||
Initialize rate limiter.
|
||||
|
||||
Args:
|
||||
max_requests: Maximum requests allowed in window
|
||||
window: Time window in seconds
|
||||
"""
|
||||
self.max_requests = max_requests
|
||||
self.window = window
|
||||
|
||||
async def __call__(self, request: Request):
|
||||
"""Check rate limit for current request."""
|
||||
# Get client identifier (IP address or user ID from JWT)
|
||||
client_id = self._get_client_id(request)
|
||||
|
||||
# Rate limit key
|
||||
key = f"rate_limit:{client_id}:{request.url.path}"
|
||||
|
||||
# Increment counter
|
||||
count = redis.incr(key)
|
||||
|
||||
# Set expiration on first request
|
||||
if count == 1:
|
||||
redis.expire(key, self.window)
|
||||
|
||||
# Check if limit exceeded
|
||||
if count > self.max_requests:
|
||||
retry_after = redis.ttl(key)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Rate limit exceeded. Try again in {retry_after} seconds.",
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
|
||||
def _get_client_id(self, request: Request) -> str:
|
||||
"""Get client identifier (IP or user ID)."""
|
||||
# Prefer user ID from JWT if available
|
||||
if hasattr(request.state, "user") and request.state.user:
|
||||
return f"user:{request.state.user.id}"
|
||||
|
||||
# Fallback to IP address
|
||||
return f"ip:{request.client.host}"
|
||||
|
||||
|
||||
# Rate limit configurations
|
||||
rate_limit_strict = RateLimiter(max_requests=10, window=60) # 10 req/min
|
||||
rate_limit_normal = RateLimiter(max_requests=100, window=60) # 100 req/min
|
||||
rate_limit_relaxed = RateLimiter(max_requests=1000, window=60) # 1000 req/min
|
||||
|
||||
|
||||
# Apply to routes:
|
||||
# from app.core.rate_limit import rate_limit_strict, rate_limit_normal
|
||||
#
|
||||
# @router.post("", dependencies=[Depends(rate_limit_strict)])
|
||||
# async def create_user():
|
||||
# """Create user (10 req/min limit)."""
|
||||
# pass
|
||||
101
skills/api-design-standards/templates/repository-pattern.py
Normal file
101
skills/api-design-standards/templates/repository-pattern.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# Grey Haven Studio - Repository Pattern Template
|
||||
# Copy this template for new resource repositories
|
||||
|
||||
from sqlmodel import Session, select, func
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from app.models.resource import Resource # TODO: Update import
|
||||
from app.schemas.resource import ResourceCreate, ResourceUpdate # TODO: Update import
|
||||
|
||||
|
||||
# TODO: Update class name
|
||||
class ResourceRepository:
|
||||
"""Repository with automatic tenant filtering for Resource model."""
|
||||
|
||||
def __init__(self, db: Session, tenant_id: str):
|
||||
self.db = db
|
||||
self.tenant_id = tenant_id
|
||||
|
||||
async def list(
|
||||
self, filters: dict = None, skip: int = 0, limit: int = 100
|
||||
) -> list[Resource]:
|
||||
"""List resources (automatically filtered by tenant_id)."""
|
||||
statement = select(Resource).where(Resource.tenant_id == self.tenant_id)
|
||||
|
||||
# Apply additional filters
|
||||
if filters:
|
||||
# TODO: Add your filter logic here
|
||||
# Example:
|
||||
# if "is_active" in filters:
|
||||
# statement = statement.where(Resource.is_active == filters["is_active"])
|
||||
pass
|
||||
|
||||
statement = statement.offset(skip).limit(limit).order_by(Resource.created_at.desc())
|
||||
|
||||
result = await self.db.execute(statement)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count(self, filters: dict = None) -> int:
|
||||
"""Count resources (tenant-isolated)."""
|
||||
statement = select(func.count(Resource.id)).where(
|
||||
Resource.tenant_id == self.tenant_id
|
||||
)
|
||||
|
||||
# Apply same filters as list()
|
||||
if filters:
|
||||
# TODO: Add same filter logic as list()
|
||||
pass
|
||||
|
||||
result = await self.db.execute(statement)
|
||||
return result.scalar_one()
|
||||
|
||||
async def get_by_id(self, resource_id: str) -> Resource | None:
|
||||
"""Get resource by ID (tenant-isolated)."""
|
||||
statement = select(Resource).where(
|
||||
Resource.id == resource_id,
|
||||
Resource.tenant_id == self.tenant_id,
|
||||
)
|
||||
result = await self.db.execute(statement)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, resource_data: ResourceCreate) -> Resource:
|
||||
"""Create a new resource in the current tenant."""
|
||||
resource = Resource(
|
||||
**resource_data.model_dump(),
|
||||
tenant_id=self.tenant_id, # Automatic tenant assignment
|
||||
)
|
||||
self.db.add(resource)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(resource)
|
||||
return resource
|
||||
|
||||
async def update(
|
||||
self, resource_id: str, resource_data: ResourceUpdate
|
||||
) -> Resource | None:
|
||||
"""Update an existing resource (tenant-isolated)."""
|
||||
resource = await self.get_by_id(resource_id)
|
||||
if not resource:
|
||||
return None
|
||||
|
||||
# Update only provided fields
|
||||
update_data = resource_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(resource, field, value)
|
||||
|
||||
await self.db.commit()
|
||||
await self.db.refresh(resource)
|
||||
return resource
|
||||
|
||||
async def delete(self, resource_id: str) -> bool:
|
||||
"""Soft-delete a resource (tenant-isolated)."""
|
||||
resource = await self.get_by_id(resource_id)
|
||||
if not resource:
|
||||
return False
|
||||
|
||||
# Soft delete by setting deleted_at timestamp
|
||||
from datetime import datetime
|
||||
|
||||
resource.deleted_at = datetime.utcnow()
|
||||
await self.db.commit()
|
||||
return True
|
||||
@@ -0,0 +1,186 @@
|
||||
// Grey Haven Studio - TanStack Start Server Function Template
|
||||
// Copy this template for new TanStack Start server functions
|
||||
|
||||
import { createServerFn } from "@tanstack/start";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/utils/db.server";
|
||||
import { resourcesTable } from "~/db/schema"; // TODO: Update import
|
||||
import { getAuthUser } from "~/utils/auth.server";
|
||||
import { eq, and, like, count, desc } from "drizzle-orm";
|
||||
|
||||
// TODO: Update validation schemas
|
||||
const createResourceSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateResourceSchema = z.object({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const listResourcesSchema = z.object({
|
||||
skip: z.number().min(0).default(0),
|
||||
limit: z.number().min(1).max(100).default(100),
|
||||
// TODO: Add filter fields
|
||||
});
|
||||
|
||||
// List resources
|
||||
export const listResources = createServerFn({ method: "GET" })
|
||||
.validator(listResourcesSchema)
|
||||
.handler(async ({ data, context }) => {
|
||||
const authUser = await getAuthUser(context);
|
||||
if (!authUser) {
|
||||
throw new Error("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// Build query with tenant filter
|
||||
let query = db
|
||||
.select()
|
||||
.from(resourcesTable)
|
||||
.where(eq(resourcesTable.tenantId, authUser.tenantId));
|
||||
|
||||
// TODO: Apply additional filters
|
||||
|
||||
// Apply pagination
|
||||
const resources = await query
|
||||
.orderBy(desc(resourcesTable.createdAt))
|
||||
.limit(data.limit)
|
||||
.offset(data.skip);
|
||||
|
||||
// Get total count
|
||||
const [{ count: total }] = await db
|
||||
.select({ count: count() })
|
||||
.from(resourcesTable)
|
||||
.where(eq(resourcesTable.tenantId, authUser.tenantId));
|
||||
|
||||
return {
|
||||
items: resources,
|
||||
total,
|
||||
skip: data.skip,
|
||||
limit: data.limit,
|
||||
hasMore: data.skip + data.limit < total,
|
||||
};
|
||||
});
|
||||
|
||||
// Get resource by ID
|
||||
export const getResource = createServerFn({ method: "GET" })
|
||||
.validator(z.object({ resourceId: z.string() }))
|
||||
.handler(async ({ data, context }) => {
|
||||
const authUser = await getAuthUser(context);
|
||||
if (!authUser) {
|
||||
throw new Error("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resourcesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcesTable.id, data.resourceId),
|
||||
eq(resourcesTable.tenantId, authUser.tenantId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
throw new Error("Resource not found", { status: 404 });
|
||||
}
|
||||
|
||||
return resource;
|
||||
});
|
||||
|
||||
// Create resource
|
||||
export const createResource = createServerFn({ method: "POST" })
|
||||
.validator(createResourceSchema)
|
||||
.handler(async ({ data, context }) => {
|
||||
const authUser = await getAuthUser(context);
|
||||
if (!authUser) {
|
||||
throw new Error("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// TODO: Check for duplicates if needed
|
||||
|
||||
// Create resource (tenant_id from auth context)
|
||||
const [resource] = await db
|
||||
.insert(resourcesTable)
|
||||
.values({
|
||||
...data,
|
||||
tenantId: authUser.tenantId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return resource;
|
||||
});
|
||||
|
||||
// Update resource
|
||||
export const updateResource = createServerFn({ method: "PUT" })
|
||||
.validator(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
data: updateResourceSchema,
|
||||
})
|
||||
)
|
||||
.handler(async ({ data, context }) => {
|
||||
const authUser = await getAuthUser(context);
|
||||
if (!authUser) {
|
||||
throw new Error("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// Verify resource exists and belongs to tenant
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(resourcesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcesTable.id, data.resourceId),
|
||||
eq(resourcesTable.tenantId, authUser.tenantId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
throw new Error("Resource not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Update resource
|
||||
const [resource] = await db
|
||||
.update(resourcesTable)
|
||||
.set({
|
||||
...data.data,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(resourcesTable.id, data.resourceId))
|
||||
.returning();
|
||||
|
||||
return resource;
|
||||
});
|
||||
|
||||
// Delete resource
|
||||
export const deleteResource = createServerFn({ method: "DELETE" })
|
||||
.validator(z.object({ resourceId: z.string() }))
|
||||
.handler(async ({ data, context }) => {
|
||||
const authUser = await getAuthUser(context);
|
||||
if (!authUser) {
|
||||
throw new Error("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
// Soft delete by setting deletedAt
|
||||
const result = await db
|
||||
.update(resourcesTable)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(resourcesTable.id, data.resourceId),
|
||||
eq(resourcesTable.tenantId, authUser.tenantId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error("Resource not found", { status: 404 });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
Reference in New Issue
Block a user