diff --git a/.vscode/settings.json b/.vscode/settings.json index 86dd464..6a04c25 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -49,5 +49,6 @@ "editor.codeActionsOnSave": { "source.fixAll.biome": "explicit", "source.organizeImports.biome": "explicit" - } + }, + "js/ts.tsdk.path": "node_modules/typescript/lib" } diff --git a/apps/server/package.json b/apps/server/package.json index fb3e078..53a53ce 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,8 @@ "tsx": "^4.19.2" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1051.0", + "@aws-sdk/s3-request-presigner": "^3.1051.0", "@fastify/cookie": "^11.0.1", "@fastify/cors": "^10.0.1", "@fastify/jwt": "^9.0.2", diff --git a/apps/server/src/config/r2.ts b/apps/server/src/config/r2.ts new file mode 100644 index 0000000..c5c34f6 --- /dev/null +++ b/apps/server/src/config/r2.ts @@ -0,0 +1,47 @@ +import { S3Client } from "@aws-sdk/client-s3"; +import { env } from "@fixr/env/server"; +import { + buildObjectPublicUrl as _buildObjectPublicUrl, + isAllowedCompanyPhotoUrl as _isAllowedCompanyPhotoUrl, +} from "../core/lib/r2"; + +export { buildUploadObjectKey, sanitizeUploadFileName } from "../core/lib/r2"; + +function parseR2BucketUrl(bucketUrl: string) { + const parsed = new URL(bucketUrl); + const pathSegments = parsed.pathname.split("/").filter(Boolean); + + if (pathSegments.length === 0) { + throw new Error( + "R2_BUCKET_URL must include the bucket name in the path (e.g. .../fixr-develop)" + ); + } + + const bucket = pathSegments[0]!; + const endpoint = `${parsed.protocol}//${parsed.host}`; + + return { endpoint, bucket }; +} + +const { endpoint, bucket } = parseR2BucketUrl(env.R2_BUCKET_URL); + +export const r2Bucket = bucket; +export const r2PublicBaseUrl = env.R2_PUBLIC_BASE_URL.replace(/\/$/, ""); +export const r2PresignExpiresIn = env.R2_PRESIGN_EXPIRES_IN; + +export const r2Client = new S3Client({ + region: env.R2_REGION, + endpoint, + credentials: { + accessKeyId: env.R2_ACCESS_KEY_ID, + secretAccessKey: env.R2_SECRET_ACCESS_KEY, + }, +}); + +export function buildObjectPublicUrl(key: string) { + return _buildObjectPublicUrl(r2PublicBaseUrl, key); +} + +export function isAllowedCompanyPhotoUrl(url: string, companyId: string) { + return _isAllowedCompanyPhotoUrl(r2PublicBaseUrl, url, companyId); +} diff --git a/apps/server/src/core/docs/companies/employees/employees.docs.ts b/apps/server/src/core/docs/companies/employees/employees.docs.ts index a670ed9..6d14d58 100644 --- a/apps/server/src/core/docs/companies/employees/employees.docs.ts +++ b/apps/server/src/core/docs/companies/employees/employees.docs.ts @@ -11,7 +11,7 @@ import { z } from "zod"; import { zodResponseSchema } from "../../types"; const getCompanyEmployeesSchema: FastifySchema = { - tags: ["Companies/Employees"], + tags: ["Employees"], summary: "Get employees", description: ` **Retrieves specified company employees** @@ -65,7 +65,7 @@ The data returned is paginated. See the [pagination](/docs/#description/paginati }; const registerEmployeeSchema: FastifySchema = { - tags: ["Companies/Employees"], + tags: ["Employees"], summary: "Register employee", description: ` **Register an employee on the system** diff --git a/apps/server/src/core/docs/service-orders.docs.ts b/apps/server/src/core/docs/service-orders.docs.ts new file mode 100644 index 0000000..0ed65a1 --- /dev/null +++ b/apps/server/src/core/docs/service-orders.docs.ts @@ -0,0 +1,177 @@ +import { + serviceOrderImageSelectSchema, + serviceOrderSelectSchema, +} from "@fixr/db/schema"; +import { getCompanyNestedDataSchema } from "@fixr/schemas/companies"; +import { + createServiceOrderMockSchema, + getServiceOrdersQuerySchema, + serviceOrderStatuses, +} from "@fixr/schemas/service-orders"; +import { paginatedDataSchema } from "@fixr/schemas/utils"; +import type { FastifySchema } from "fastify"; +import { z } from "zod"; +import { zodResponseSchema } from "./types"; + +const createServiceOrderResponseDataSchema = serviceOrderSelectSchema.extend({ + photos: z.array(serviceOrderImageSelectSchema), +}); + +const serviceOrderListRecordSchema = z.object({ + ...serviceOrderSelectSchema.shape, + client: z.object({ + id: z.string(), + name: z.string(), + }), + employee: z.object({ + id: z.string(), + name: z.string(), + }), + deviceCategory: z.object({ + id: z.string(), + name: z.string(), + }), + deviceMaker: z.object({ + id: z.string(), + name: z.string(), + }), +}); + +const getCompanyServiceOrdersSchema: FastifySchema = { + tags: ["Service Orders"], + summary: "List service orders", + description: ` +**Retrieves company service orders (paginated)** + +Optional filters (query string): +- \`deviceCategoryId\` — device category (cuid2) +- \`employeeId\` — responsible employee (cuid2) +- \`status\` — one of: ${serviceOrderStatuses.options.join(", ")} +- \`dateFrom\` / \`dateTo\` — filter by \`created_at\` (inclusive; ISO date or datetime) +- \`query\` — search in device model, reported defect, or client name +- \`page\`, \`perPage\`, \`sort\` (\`newer\` | \`older\`) — pagination (see API pagination docs) +`, + params: getCompanyNestedDataSchema, + querystring: getServiceOrdersQuerySchema, + response: { + 200: zodResponseSchema({ + status: 200, + error: null, + message: "Company service orders successfully retrieved.", + code: "get_company_service_orders_success", + data: paginatedDataSchema(serviceOrderListRecordSchema), + }).describe("Service orders retrieved successfully."), + 416: zodResponseSchema({ + status: 416, + error: "Range Not Satisfiable", + code: "page_out_of_bounds", + message: "The requested page exceeds the total number of pages.", + data: null, + }).describe("Requested page exceeds total pages."), + 403: zodResponseSchema({ + status: 403, + error: "Forbidden", + code: "not_allowed", + message: "You are not authorized to access this company.", + data: null, + }).describe("Not allowed to access this company."), + 404: zodResponseSchema({ + status: 404, + error: "Not Found", + code: "company_not_found", + message: "There's no companies bound to your account", + data: null, + }).describe("User is not associated with a company."), + }, + security: [{ JWT: [] }], +}; + +const createServiceOrderSchemaDoc: FastifySchema = { + tags: ["Service Orders"], + summary: "Create service order", + description: ` +**Creates a new service order for the authenticated company** + +The \`company_id\` and \`employee_id\` are inferred from the authenticated employee session (\`req.user\`). +Photo uploads are assumed to have been completed beforehand via pre-signed URLs; this endpoint only persists metadata. + +Rules: +- Only authenticated employees can create service orders. +- The \`subdomain\` in the URL must match the employee's company. +`, + params: getCompanyNestedDataSchema, + body: createServiceOrderMockSchema, + response: { + 201: zodResponseSchema({ + status: 201, + error: null, + message: "Service order created successfully.", + code: "create_service_order_success", + data: createServiceOrderResponseDataSchema, + }).describe("Service order created successfully."), + 400: zodResponseSchema({ + status: 400, + error: "Bad Request", + code: "upload_not_found", + message: + "One or more uploads were not found or do not belong to this company.", + data: null, + }).describe("Upload was not found or does not belong to this company."), + 403: z + .union([ + zodResponseSchema({ + status: 403, + error: "Forbidden", + code: "not_allowed", + message: "You are not allowed to perform this action.", + data: null, + }), + zodResponseSchema({ + status: 403, + error: "Forbidden", + code: "employee_not_found", + message: "Employee profile not found for this account.", + data: null, + }), + ]) + .describe("User is not allowed to perform this action."), + 404: z + .union([ + zodResponseSchema({ + status: 404, + error: "Not Found", + code: "company_not_found", + message: "There's no companies bound to your account", + data: null, + }), + zodResponseSchema({ + status: 404, + error: "Not Found", + code: "client_not_found", + message: "Client not found.", + data: null, + }), + zodResponseSchema({ + status: 404, + error: "Not Found", + code: "device_brand_not_found", + message: "Device brand not found.", + data: null, + }), + zodResponseSchema({ + status: 404, + error: "Not Found", + code: "device_category_not_found", + message: "Device category not found.", + data: null, + }), + ]) + .describe("Referenced resource was not found."), + }, + security: [{ JWT: [] }], +}; + +export const serviceOrdersDocs = { + getCompanyServiceOrdersSchema, + createServiceOrderSchema: createServiceOrderSchemaDoc, +}; diff --git a/apps/server/src/core/docs/uploads.docs.ts b/apps/server/src/core/docs/uploads.docs.ts new file mode 100644 index 0000000..63b1d9e --- /dev/null +++ b/apps/server/src/core/docs/uploads.docs.ts @@ -0,0 +1,47 @@ +import { + createUploadPresignSchema, + uploadPresignResponseSchema, +} from "@fixr/schemas/uploads"; +import type { FastifySchema } from "fastify"; +import { zodResponseSchema } from "./types"; + +const createUploadPresignSchemaDoc: FastifySchema = { + tags: ["Uploads"], + summary: "Generate pre-signed upload URL for service order images", + description: ` +**Generates a pre-signed URL for direct upload to Cloudflare R2** + +Creates a pending upload record and returns a time-limited pre-signed PUT URL. Use the returned URL to upload the file, then pass the \`id\` when creating the service order. + +The request accepts file metadata (\`fileName\`, \`contentType\`, \`size\`) and returns the upload ID, upload URL, object key, public URL and expiration time. +`, + body: createUploadPresignSchema, + response: { + 200: zodResponseSchema({ + status: 200, + error: null, + message: "Upload URL generated successfully.", + code: "create_upload_presign_success", + data: uploadPresignResponseSchema, + }).describe("Pre-signed upload URL generated."), + 403: zodResponseSchema({ + status: 403, + error: "Forbidden", + code: "not_allowed", + message: "You are not authorized to perform this action.", + data: null, + }), + 404: zodResponseSchema({ + status: 404, + error: "Not Found", + code: "company_not_found", + message: "There's no company associated with this account.", + data: null, + }), + }, + security: [{ JWT: [] }], +}; + +export const uploadsDocs = { + createUploadPresignSchema: createUploadPresignSchemaDoc, +}; diff --git a/apps/server/src/core/errors/index.ts b/apps/server/src/core/errors/index.ts index 1faa588..6156b0a 100644 --- a/apps/server/src/core/errors/index.ts +++ b/apps/server/src/core/errors/index.ts @@ -3,7 +3,9 @@ import { authErrors } from "../../modules/auth/errors"; import { companiesErrors } from "../../modules/companies/errors"; import { credentialsErrors } from "../../modules/credentials/errors"; import { employeesErrors } from "../../modules/employees/errors"; +import { serviceOrdersErrors } from "../../modules/service-orders/errors"; import { tokensErrors } from "../../modules/tokens/errors"; +import { uploadsErrors } from "../../modules/uploads/errors"; import { defineErrors } from "../utils/errors"; export const errors = defineErrors({ @@ -12,7 +14,9 @@ export const errors = defineErrors({ ...credentialsErrors, ...companiesErrors, ...employeesErrors, + ...serviceOrdersErrors, ...tokensErrors, + ...uploadsErrors, INTERNAL_ERROR: { code: "internal_error", diff --git a/apps/server/src/core/lib/pagination.ts b/apps/server/src/core/lib/pagination.ts index df1ec77..86cc59b 100644 --- a/apps/server/src/core/lib/pagination.ts +++ b/apps/server/src/core/lib/pagination.ts @@ -66,23 +66,43 @@ export async function getPaginatedRecords({ const query = baseQuery.orderBy(order).limit(take).offset(skip); - // Cast the result to the expected type inferred from the select parameter (or table columns) return (await query) as unknown[]; } -/** - * Counts the total number of records matching the given condition. - */ export async function getPaginatedCount({ table, where, + joins, }: { table: unknown; where?: SQL; + joins?: Join[]; }) { const tableRef = table as MySqlTable; const query = db.select({ count: count() }).from(tableRef).$dynamic(); + if (joins) { + for (const join of joins) { + const joinTable = join.table as MySqlTable; + switch (join.type) { + case "inner": + query.innerJoin(joinTable, join.on); + break; + case "left": + query.leftJoin(joinTable, join.on); + break; + case "right": + query.rightJoin(joinTable, join.on); + break; + case "full": + query.fullJoin(joinTable, join.on); + break; + default: + break; + } + } + } + if (where) { query.where(where); } diff --git a/apps/server/src/core/lib/r2.ts b/apps/server/src/core/lib/r2.ts new file mode 100644 index 0000000..6e0af8d --- /dev/null +++ b/apps/server/src/core/lib/r2.ts @@ -0,0 +1,58 @@ +import { randomUUID } from "node:crypto"; + +const PATH_SEPARATOR_REGEX = /[/\\]/; +const UNSAFE_FILENAME_CHARS_REGEX = /[^\w.-]+/g; +const DUPLICATE_DASHES_REGEX = /-+/g; +const TRIM_DASHES_REGEX = /^-|-$/g; + +/** + * Sanitize a file name for safe R2 object key usage + */ +export function sanitizeUploadFileName(fileName: string): string { + const baseName = fileName.split(PATH_SEPARATOR_REGEX).pop() ?? fileName; + const sanitized = baseName + .normalize("NFKD") + .replace(UNSAFE_FILENAME_CHARS_REGEX, "-") + .replace(DUPLICATE_DASHES_REGEX, "-") + .replace(TRIM_DASHES_REGEX, ""); + + return sanitized.length > 0 ? sanitized.slice(0, 200) : "upload"; +} + +/** + * Build the public URL for an R2 object key + */ +export function buildObjectPublicUrl( + publicBaseUrl: string, + key: string +): string { + return `${publicBaseUrl}/${key}`; +} + +/** + * Check if a photo URL was issued by a specific company's upload flow + */ +export function isAllowedCompanyPhotoUrl( + publicBaseUrl: string, + url: string, + companyId: string +): boolean { + const prefix = `${publicBaseUrl}/companies/${companyId}/service-orders/`; + return url.startsWith(prefix); +} + +/** + * Build an R2 object key for a company upload + */ +export function buildUploadObjectKey({ + companyId, + fileName, +}: { + companyId: string; + fileName: string; +}): string { + const safeName = sanitizeUploadFileName(fileName); + const uniquePrefix = `${Date.now()}-${randomUUID().slice(0, 8)}`; + + return `companies/${companyId}/service-orders/${uniquePrefix}-${safeName}`; +} diff --git a/apps/server/src/modules/service-orders/controllers/index.ts b/apps/server/src/modules/service-orders/controllers/index.ts new file mode 100644 index 0000000..e44fc29 --- /dev/null +++ b/apps/server/src/modules/service-orders/controllers/index.ts @@ -0,0 +1,64 @@ +import type { jwtPayload } from "@fixr/schemas/auth"; +import type { + createServiceOrderMockSchema, + getServiceOrdersQuerySchema, +} from "@fixr/schemas/service-orders"; +import type { FastifyReply } from "fastify"; +import type { z } from "zod"; +import { ServiceOrdersService } from "../services"; + +/** @description Service orders request handlers */ +export class ServiceOrdersController { + static getCompanyServiceOrders({ + userJwt, + subdomain, + page, + perPage, + query, + sort, + deviceCategoryId, + employeeId, + status, + dateFrom, + dateTo, + response, + }: { + userJwt: z.infer; + subdomain: string; + response: FastifyReply; + } & z.infer) { + return ServiceOrdersService.getCompanyServiceOrders({ + userJwt, + subdomain, + page, + perPage, + query, + sort, + deviceCategoryId, + employeeId, + status, + dateFrom, + dateTo, + response, + }); + } + + static createServiceOrder({ + userJwt, + subdomain, + data, + response, + }: { + userJwt: z.infer; + subdomain: string; + data: z.infer; + response: FastifyReply; + }) { + return ServiceOrdersService.createServiceOrder({ + userJwt, + subdomain, + data, + response, + }); + } +} diff --git a/apps/server/src/modules/service-orders/errors/index.ts b/apps/server/src/modules/service-orders/errors/index.ts new file mode 100644 index 0000000..3a4c846 --- /dev/null +++ b/apps/server/src/modules/service-orders/errors/index.ts @@ -0,0 +1,45 @@ +import { defineErrors } from "../../../core/utils/errors"; + +export const serviceOrdersErrors = defineErrors({ + SERVICE_ORDER_COMPANY_NOT_FOUND: { + code: "company_not_found", + message: "There's no companies bound to your account", + status: 404, + }, + SERVICE_ORDER_NOT_ALLOWED: { + code: "not_allowed", + message: "You are not authorized to access this company.", + status: 403, + }, + SERVICE_ORDER_EMPLOYEE_NOT_FOUND: { + code: "employee_not_found", + message: "Employee profile not found for this account.", + status: 403, + }, + SERVICE_ORDER_CLIENT_NOT_FOUND: { + code: "client_not_found", + message: "Client not found.", + status: 404, + }, + SERVICE_ORDER_DEVICE_BRAND_NOT_FOUND: { + code: "device_brand_not_found", + message: "Device brand not found.", + status: 404, + }, + SERVICE_ORDER_DEVICE_CATEGORY_NOT_FOUND: { + code: "device_category_not_found", + message: "Device category not found.", + status: 404, + }, + SERVICE_ORDER_UPLOAD_NOT_FOUND: { + code: "upload_not_found", + message: + "One or more uploads were not found or do not belong to this company.", + status: 400, + }, + SERVICE_ORDER_PAGE_OUT_OF_BOUNDS: { + code: "page_out_of_bounds", + message: "The requested page exceeds the total number of pages.", + status: 416, + }, +}); diff --git a/apps/server/src/modules/service-orders/repositories/index.ts b/apps/server/src/modules/service-orders/repositories/index.ts new file mode 100644 index 0000000..c9333fe --- /dev/null +++ b/apps/server/src/modules/service-orders/repositories/index.ts @@ -0,0 +1,227 @@ +import { + and, + db, + eq, + gte, + inArray, + like, + lte, + or, + type SQL, +} from "@fixr/db/connection"; +import { + clients, + employees, + modelCategories, + modelMakers, + serviceOrderImages, + serviceOrders, + uploads, +} from "@fixr/db/schema"; +import type { + createServiceOrderMockSchema, + getServiceOrdersQuerySchema, +} from "@fixr/schemas/service-orders"; +import type { z } from "zod"; + +function endOfDay(date: Date) { + const end = new Date(date); + end.setHours(23, 59, 59, 999); + return end; +} + +export const serviceOrdersListSelect = { + id: serviceOrders.id, + companyId: serviceOrders.companyId, + clientId: serviceOrders.clientId, + employeeId: serviceOrders.employeeId, + deviceMakerId: serviceOrders.deviceMakerId, + deviceCategoryId: serviceOrders.deviceCategoryId, + deviceModel: serviceOrders.deviceModel, + imei: serviceOrders.imei, + reportedDefect: serviceOrders.reportedDefect, + observations: serviceOrders.observations, + status: serviceOrders.status, + createdAt: serviceOrders.createdAt, + updatedAt: serviceOrders.updatedAt, + client: { id: clients.id, name: clients.name }, + employee: { id: employees.id, name: employees.name }, + deviceCategory: { id: modelCategories.id, name: modelCategories.name }, + deviceMaker: { id: modelMakers.id, name: modelMakers.name }, +}; + +export const serviceOrdersListJoins = [ + { + type: "inner" as const, + table: clients, + on: eq(clients.id, serviceOrders.clientId), + }, + { + type: "inner" as const, + table: employees, + on: eq(employees.id, serviceOrders.employeeId), + }, + { + type: "inner" as const, + table: modelCategories, + on: eq(modelCategories.id, serviceOrders.deviceCategoryId), + }, + { + type: "inner" as const, + table: modelMakers, + on: eq(modelMakers.id, serviceOrders.deviceMakerId), + }, +]; + +export class ServiceOrdersRepository { + static async queryEmployeeByUserId(userId: string) { + const [employee] = await db + .select() + .from(employees) + .where(eq(employees.userId, userId)) + .limit(1); + return employee ?? null; + } + + static async queryClientById(clientId: string) { + const [client] = await db + .select() + .from(clients) + .where(eq(clients.id, clientId)) + .limit(1); + return client ?? null; + } + + static async queryDeviceMakerById(deviceMakerId: string) { + const [maker] = await db + .select() + .from(modelMakers) + .where(eq(modelMakers.id, deviceMakerId)) + .limit(1); + return maker ?? null; + } + + static async queryDeviceCategoryById(deviceCategoryId: string) { + const [category] = await db + .select() + .from(modelCategories) + .where(eq(modelCategories.id, deviceCategoryId)) + .limit(1); + return category ?? null; + } + + static buildListFilter( + companyId: string, + filters: Pick< + z.infer, + | "query" + | "deviceCategoryId" + | "employeeId" + | "status" + | "dateFrom" + | "dateTo" + > + ) { + const conditions: SQL[] = [eq(serviceOrders.companyId, companyId)]; + + if (filters.deviceCategoryId) { + conditions.push( + eq(serviceOrders.deviceCategoryId, filters.deviceCategoryId) + ); + } + if (filters.employeeId) { + conditions.push(eq(serviceOrders.employeeId, filters.employeeId)); + } + if (filters.status) { + conditions.push(eq(serviceOrders.status, filters.status)); + } + if (filters.dateFrom) { + conditions.push(gte(serviceOrders.createdAt, filters.dateFrom)); + } + if (filters.dateTo) { + conditions.push(lte(serviceOrders.createdAt, endOfDay(filters.dateTo))); + } + if (filters.query) { + conditions.push( + or( + like(serviceOrders.deviceModel, `%${filters.query}%`), + like(serviceOrders.reportedDefect, `%${filters.query}%`), + like(clients.name, `%${filters.query}%`) + )! + ); + } + + return and(...conditions); + } + + static async createWithPhotos({ + companyId, + employeeId, + data, + }: { + companyId: string; + employeeId: string; + data: z.infer; + }) { + return await db.transaction(async (tx) => { + const [serviceOrderId] = await tx + .insert(serviceOrders) + .values({ + companyId, + clientId: data.clientId, + employeeId, + deviceMakerId: data.deviceBrandId, + deviceCategoryId: data.deviceCategoryId, + deviceModel: data.deviceModel, + imei: data.imei ?? null, + reportedDefect: data.reportedDefect, + observations: data.observations ?? null, + }) + .$returningId(); + + if (data.photos.length > 0) { + const uploadIds = data.photos.map((p) => p.uploadId); + const uploadRecords = await tx + .select() + .from(uploads) + .where(inArray(uploads.id, uploadIds)); + + const uploadMap = new Map(uploadRecords.map((u) => [u.id, u])); + + await tx.insert(serviceOrderImages).values( + data.photos.map((photo) => { + const upload = uploadMap.get(photo.uploadId)!; + return { + serviceOrderId: serviceOrderId.id, + employeeId, + uploadId: photo.uploadId, + imageUrl: upload.url, + fileName: upload.fileName, + sizeInBytes: upload.sizeInBytes, + contentType: upload.contentType, + description: photo.description ?? null, + }; + }) + ); + + await tx + .update(uploads) + .set({ status: "completed" }) + .where(inArray(uploads.id, uploadIds)); + } + + const [serviceOrder] = await tx + .select() + .from(serviceOrders) + .where(eq(serviceOrders.id, serviceOrderId.id)) + .limit(1); + + const photos = await tx + .select() + .from(serviceOrderImages) + .where(eq(serviceOrderImages.serviceOrderId, serviceOrderId.id)); + + return { serviceOrder, photos }; + }); + } +} diff --git a/apps/server/src/modules/service-orders/routes/index.ts b/apps/server/src/modules/service-orders/routes/index.ts new file mode 100644 index 0000000..c4374cc --- /dev/null +++ b/apps/server/src/modules/service-orders/routes/index.ts @@ -0,0 +1,63 @@ +import { permissions } from "@fixr/permissions"; +import type { userJWT } from "@fixr/schemas/auth"; +import { getCompanyNestedDataSchema } from "@fixr/schemas/companies"; +import { + createServiceOrderMockSchema, + getServiceOrdersQuerySchema, +} from "@fixr/schemas/service-orders"; +import type { z } from "zod"; +import { serviceOrdersDocs } from "../../../core/docs/service-orders.docs"; +import type { FastifyTypedInstance } from "../../../core/interfaces/fastify"; +import { authenticateEmployee } from "../../../core/middlewares/authenticate-employee"; +import { requirePermission } from "../../../core/middlewares/rbac"; +import { withErrorHandler } from "../../../core/middlewares/with-error-handler"; +import { ServiceOrdersController } from "../controllers"; + +/** @description Service orders routes plugin */ +export function serviceOrdersRoutes(fastify: FastifyTypedInstance) { + fastify.get( + "/", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.serviceOrders.read), + ], + schema: serviceOrdersDocs.getCompanyServiceOrdersSchema, + }, + withErrorHandler(async (request, response) => { + const userJwt = request.user as z.infer; + const query = getServiceOrdersQuerySchema.parse(request.query); + const { subdomain } = getCompanyNestedDataSchema.parse(request.params); + + await ServiceOrdersController.getCompanyServiceOrders({ + userJwt, + subdomain, + response, + ...query, + }); + }) + ); + + fastify.post( + "/", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.serviceOrders.create), + ], + schema: serviceOrdersDocs.createServiceOrderSchema, + }, + withErrorHandler(async (request, response) => { + const userJwt = request.user as z.infer; + const body = await createServiceOrderMockSchema.parseAsync(request.body); + const { subdomain } = getCompanyNestedDataSchema.parse(request.params); + + await ServiceOrdersController.createServiceOrder({ + userJwt, + data: body, + subdomain, + response, + }); + }) + ); +} diff --git a/apps/server/src/modules/service-orders/services/index.ts b/apps/server/src/modules/service-orders/services/index.ts new file mode 100644 index 0000000..d5beed1 --- /dev/null +++ b/apps/server/src/modules/service-orders/services/index.ts @@ -0,0 +1,216 @@ +import { asc, db, desc, inArray } from "@fixr/db/connection"; +import { serviceOrders as serviceOrdersTable, uploads } from "@fixr/db/schema"; +import type { jwtPayload } from "@fixr/schemas/auth"; +import type { + createServiceOrderMockSchema, + getServiceOrdersQuerySchema, +} from "@fixr/schemas/service-orders"; +import type { FastifyReply } from "fastify"; +import type { z } from "zod"; +import { AppError } from "../../../core/lib/app-error"; +import { + getPaginatedCount, + getPaginatedRecords, +} from "../../../core/lib/pagination"; +import { apiResponse, paginatedData } from "../../../core/lib/response"; +import { + ServiceOrdersRepository, + serviceOrdersListJoins, + serviceOrdersListSelect, +} from "../repositories"; + +export class ServiceOrdersService { + static async getCompanyServiceOrders({ + userJwt, + subdomain, + page, + perPage, + query, + sort, + deviceCategoryId, + employeeId, + status, + dateFrom, + dateTo, + response, + }: { + userJwt: z.infer; + subdomain: string; + response: FastifyReply; + } & z.infer) { + if (!userJwt.company) { + throw new AppError("SERVICE_ORDER_COMPANY_NOT_FOUND"); + } + + const companyId = userJwt.company.id; + + if (userJwt.company.subdomain !== subdomain) { + throw new AppError("SERVICE_ORDER_NOT_ALLOWED"); + } + + const PER_PAGE = perPage ?? 10; + + const order = + sort === "newer" || !sort + ? desc(serviceOrdersTable.createdAt) + : asc(serviceOrdersTable.createdAt); + + const filter = ServiceOrdersRepository.buildListFilter(companyId, { + query, + deviceCategoryId, + employeeId, + status, + dateFrom, + dateTo, + }); + + const [records, totalRecords] = await Promise.all([ + getPaginatedRecords({ + table: serviceOrdersTable, + select: serviceOrdersListSelect, + skip: (page - 1) * PER_PAGE, + take: PER_PAGE, + where: filter, + order, + joins: serviceOrdersListJoins, + }), + getPaginatedCount({ + table: serviceOrdersTable, + where: filter, + joins: query ? serviceOrdersListJoins : undefined, + }), + ]); + + if (totalRecords === 0) { + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + message: "Company service orders successfully retrieved.", + code: "get_company_service_orders_success", + data: paginatedData({ + records: [], + pagination: { + total_records: 0, + total_pages: 0, + current_page: 1, + next_page: null, + prev_page: null, + }, + }), + }) + ); + } + + const total_pages = Math.ceil(totalRecords / PER_PAGE); + + if (page > total_pages) { + throw new AppError("SERVICE_ORDER_PAGE_OUT_OF_BOUNDS"); + } + + const next_page = + PER_PAGE * (page - 1) + records.length < totalRecords ? page + 1 : null; + + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + message: "Company service orders successfully retrieved.", + code: "get_company_service_orders_success", + data: paginatedData({ + records, + pagination: { + total_records: totalRecords, + total_pages, + current_page: page, + next_page, + prev_page: page > 1 ? page - 1 : null, + }, + }), + }) + ); + } + + static async createServiceOrder({ + userJwt, + subdomain, + data, + response, + }: { + userJwt: z.infer; + subdomain: string; + data: z.infer; + response: FastifyReply; + }) { + if (!userJwt.company) { + throw new AppError("SERVICE_ORDER_COMPANY_NOT_FOUND"); + } + + const companyId = userJwt.company.id; + + if (userJwt.company.subdomain !== subdomain) { + throw new AppError("SERVICE_ORDER_NOT_ALLOWED"); + } + + const employee = await ServiceOrdersRepository.queryEmployeeByUserId( + userJwt.id + ); + + if (!employee || employee.companyId !== companyId) { + throw new AppError("SERVICE_ORDER_EMPLOYEE_NOT_FOUND"); + } + + const [client, deviceMaker, deviceCategory] = await Promise.all([ + ServiceOrdersRepository.queryClientById(data.clientId), + ServiceOrdersRepository.queryDeviceMakerById(data.deviceBrandId), + ServiceOrdersRepository.queryDeviceCategoryById(data.deviceCategoryId), + ]); + + if (!client) { + throw new AppError("SERVICE_ORDER_CLIENT_NOT_FOUND"); + } + if (!deviceMaker) { + throw new AppError("SERVICE_ORDER_DEVICE_BRAND_NOT_FOUND"); + } + if (!deviceCategory) { + throw new AppError("SERVICE_ORDER_DEVICE_CATEGORY_NOT_FOUND"); + } + + if (data.photos.length > 0) { + const uploadIds = data.photos.map((p) => p.uploadId); + const foundUploads = await db + .select() + .from(uploads) + .where(inArray(uploads.id, uploadIds)); + + const foundMap = new Map(foundUploads.map((u) => [u.id, u])); + + for (const uploadId of uploadIds) { + const upload = foundMap.get(uploadId); + if (!upload || upload.companyId !== companyId) { + throw new AppError("SERVICE_ORDER_UPLOAD_NOT_FOUND"); + } + } + } + + const { serviceOrder, photos } = + await ServiceOrdersRepository.createWithPhotos({ + companyId, + employeeId: employee.id, + data, + }); + + return response.status(201).send( + apiResponse({ + status: 201, + error: null, + code: "create_service_order_success", + message: "Service order created successfully.", + data: { + ...serviceOrder, + photos, + }, + }) + ); + } +} diff --git a/apps/server/src/modules/uploads/controllers/index.ts b/apps/server/src/modules/uploads/controllers/index.ts new file mode 100644 index 0000000..48e0b5d --- /dev/null +++ b/apps/server/src/modules/uploads/controllers/index.ts @@ -0,0 +1,24 @@ +import type { jwtPayload } from "@fixr/schemas/auth"; +import type { createUploadPresignSchema } from "@fixr/schemas/uploads"; +import type { FastifyReply } from "fastify"; +import type { z } from "zod"; +import { UploadsService } from "../services"; + +/** @description Uploads request handlers */ +export class UploadsController { + static createPresignedUpload({ + userJwt, + data, + response, + }: { + userJwt: z.infer; + data: z.infer; + response: FastifyReply; + }) { + return UploadsService.createPresignedUpload({ + userJwt, + data, + response, + }); + } +} diff --git a/apps/server/src/modules/uploads/errors/index.ts b/apps/server/src/modules/uploads/errors/index.ts new file mode 100644 index 0000000..911fdbf --- /dev/null +++ b/apps/server/src/modules/uploads/errors/index.ts @@ -0,0 +1,9 @@ +import { defineErrors } from "../../../core/utils/errors"; + +export const uploadsErrors = defineErrors({ + UPLOAD_COMPANY_NOT_FOUND: { + code: "company_not_found", + message: "There's no companies bound to your account", + status: 404, + }, +}); diff --git a/apps/server/src/modules/uploads/repositories/index.ts b/apps/server/src/modules/uploads/repositories/index.ts new file mode 100644 index 0000000..1596c7d --- /dev/null +++ b/apps/server/src/modules/uploads/repositories/index.ts @@ -0,0 +1,61 @@ +import { PutObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { db } from "@fixr/db/connection"; +import { uploads } from "@fixr/db/schema"; +import type { createUploadPresignSchema } from "@fixr/schemas/uploads"; +import type { z } from "zod"; +import { + buildObjectPublicUrl, + buildUploadObjectKey, + r2Bucket, + r2Client, + r2PresignExpiresIn, +} from "../../../config/r2"; + +export class UploadsRepository { + static async createPresignedUpload({ + companyId, + employeeId, + data, + }: { + companyId: string; + employeeId: string; + data: z.infer; + }) { + const key = buildUploadObjectKey({ companyId, fileName: data.fileName }); + const url = buildObjectPublicUrl(key); + + const command = new PutObjectCommand({ + Bucket: r2Bucket, + Key: key, + ContentType: data.contentType, + ContentLength: data.size, + }); + + const uploadUrl = await getSignedUrl(r2Client, command, { + expiresIn: r2PresignExpiresIn, + }); + + const [record] = await db + .insert(uploads) + .values({ + companyId, + employeeId, + key, + url, + fileName: data.fileName, + contentType: data.contentType, + sizeInBytes: data.size, + status: "pending", + }) + .$returningId(); + + return { + id: record.id, + uploadUrl, + key, + url, + expiresIn: r2PresignExpiresIn, + }; + } +} diff --git a/apps/server/src/modules/uploads/routes/index.ts b/apps/server/src/modules/uploads/routes/index.ts new file mode 100644 index 0000000..8bf4698 --- /dev/null +++ b/apps/server/src/modules/uploads/routes/index.ts @@ -0,0 +1,33 @@ +import { permissions } from "@fixr/permissions"; +import type { userJWT } from "@fixr/schemas/auth"; +import { createUploadPresignSchema } from "@fixr/schemas/uploads"; +import type { z } from "zod"; +import { uploadsDocs } from "../../../core/docs/uploads.docs"; +import type { FastifyTypedInstance } from "../../../core/interfaces/fastify"; +import { authenticateEmployee } from "../../../core/middlewares/authenticate-employee"; +import { requirePermission } from "../../../core/middlewares/rbac"; +import { withErrorHandler } from "../../../core/middlewares/with-error-handler"; +import { UploadsController } from "../controllers"; + +export function uploadsRoutes(fastify: FastifyTypedInstance) { + fastify.post( + "/service-orders/presign", + { + preHandler: [ + authenticateEmployee, + requirePermission(permissions.serviceOrders.update), + ], + schema: uploadsDocs.createUploadPresignSchema, + }, + withErrorHandler(async (request, response) => { + const userJwt = request.user as z.infer; + const body = await createUploadPresignSchema.parseAsync(request.body); + + await UploadsController.createPresignedUpload({ + userJwt, + data: body, + response, + }); + }) + ); +} diff --git a/apps/server/src/modules/uploads/services/index.ts b/apps/server/src/modules/uploads/services/index.ts new file mode 100644 index 0000000..b390bb3 --- /dev/null +++ b/apps/server/src/modules/uploads/services/index.ts @@ -0,0 +1,51 @@ +import { db, eq } from "@fixr/db/connection"; +import { employees } from "@fixr/db/schema"; +import type { jwtPayload } from "@fixr/schemas/auth"; +import type { createUploadPresignSchema } from "@fixr/schemas/uploads"; +import type { FastifyReply } from "fastify"; +import type { z } from "zod"; +import { AppError } from "../../../core/lib/app-error"; +import { apiResponse } from "../../../core/lib/response"; +import { UploadsRepository } from "../repositories"; + +export class UploadsService { + static async createPresignedUpload({ + userJwt, + data, + response, + }: { + userJwt: z.infer; + data: z.infer; + response: FastifyReply; + }) { + if (!userJwt.company) { + throw new AppError("UPLOAD_COMPANY_NOT_FOUND"); + } + + const [employee] = await db + .select() + .from(employees) + .where(eq(employees.userId, userJwt.id)) + .limit(1); + + if (!employee || employee.companyId !== userJwt.company.id) { + throw new AppError("UPLOAD_COMPANY_NOT_FOUND"); + } + + const presign = await UploadsRepository.createPresignedUpload({ + companyId: userJwt.company.id, + employeeId: employee.id, + data, + }); + + return response.status(200).send( + apiResponse({ + status: 200, + error: null, + code: "create_upload_presign_success", + message: "Upload URL generated successfully.", + data: presign, + }) + ); + } +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 58f5da2..4546008 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -28,12 +28,13 @@ import { cookieKey } from "./../../../packages/constants/src/cookies"; import { apiDescription } from "./core/docs/main"; import { AppError } from "./core/lib/app-error"; import { apiResponse } from "./core/lib/response"; -import { setupRBAC } from "./core/middlewares/rbac"; import { accountRoutes } from "./modules/account/routes"; import { authRoutes } from "./modules/auth/routes"; import { companiesRoutes } from "./modules/companies/routes"; import { credentialsRoutes } from "./modules/credentials/routes"; import { employeesRoutes } from "./modules/employees/routes"; +import { serviceOrdersRoutes } from "./modules/service-orders/routes"; +import { uploadsRoutes } from "./modules/uploads/routes"; const envToLogger = { development: { @@ -62,124 +63,22 @@ const server = fastify({ server.setValidatorCompiler(validatorCompiler); server.setSerializerCompiler(serializerCompiler); -//Set Swagger as the openapi docs generator -server.register(fastifySwagger, { - openapi: { - info: { - title: `${APP_NAME} API`, - version: "1.0.0", - summary: `${APP_NAME} API`, - description: apiDescription, - }, - tags: [ - { - name: "Auth", - description: - "Routes used for authentication (register, login and confirmations)", - }, - { - name: "Account", - description: "Edit account data or delete it through these routes.", - }, - { - name: "Credentials", - description: "Change account credentials (password) in different ways.", - }, - { - name: "Companies", - description: "Company management.", - }, - { - name: "Companies/Employees", - description: "Manage company employees.", - }, - ], - security: [], - components: { - securitySchemes: { - JWT: { - type: "http", - scheme: "bearer", - bearerFormat: "Bearer", - }, - }, - }, - }, - transform: jsonSchemaTransform, - transformObject: jsonSchemaTransformObject, -}); - -//Set Scalar as the frontend for the docs -server.register(scalarUi, { - routePrefix: "/docs", - configuration: { - url: "/reference/json", - metaData: { - title: `Docs - ${APP_NAME} API`, - }, - favicon: "/public/favicon.ico", - theme: "none", - }, -}); - -//Also register Swagger for the classic API reference -server.register(fastifySwaggerUi, { - routePrefix: "/reference", -}); - -//Register routes and plugins. -server.register(fastifyJwt, { - secret: env.JWT_SECRET, - cookie: { - cookieName: cookieKey("session"), - signed: false, - }, -}); - -server.register(fastifyCookie, { - secret: env.COOKIE_ENCRYPTION_SECRET, -}); - -setupRBAC(server); - -server.register(fastifyStatic, { - root: join(cwd(), "public"), - prefix: "/public/", -}); - -server.register(fastifyCors, { - origin: [ - env.FRONTEND_URL, - env.ADMIN_URL, - `http://localhost:${env.NODE_PORT}`, - ], - credentials: true, -}); - -server.register(authRoutes, { - prefix: "/auth", -}); - -server.register(accountRoutes, { - prefix: "/account", -}); - -server.register(credentialsRoutes, { - prefix: "/credentials", -}); - -server.register(companiesRoutes, { - prefix: "/companies", -}); - -server.register(employeesRoutes, { - prefix: "/companies/:subdomain/employees", -}); +//Map the zod errors to standard response +server.setErrorHandler((error, _request, reply) => { + if (error instanceof ZodError) { + reply.status(400).send( + apiResponse({ + status: 400, + error: "Bad Request", + code: "bad_request", + message: "Type validation failed", + data: error.issues, + }) + ); + return; + } -server.get("/", (_, reply) => { - reply - .status(200) - .send("Hello from Fixr API! Reach the documentation at /docs"); + reply.send(error); }); server.setErrorHandler((error, request, response) => { @@ -213,50 +112,169 @@ server.setErrorHandler((error, request, response) => { }) ); } +}); - if (error instanceof ZodError) { - return response.status(400).send( - apiResponse({ - status: 400, - error: "Bad Request", - code: "bad_request", - message: "Type validation failed", - data: error.issues, - }) - ); - } +async function registerPlugins() { + // @fastify/swagger must be registered before routes (route discovery). + await server.register(fastifySwagger, { + openapi: { + info: { + title: `${APP_NAME} API`, + version: "1.0.0", + summary: `${APP_NAME} API`, + description: apiDescription, + }, + tags: [ + { + name: "Auth", + description: + "Routes used for authentication (register, login and confirmations)", + }, + { + name: "Account", + description: "Edit account data or delete it through these routes.", + }, + { + name: "Credentials", + description: + "Change account credentials (password) in different ways.", + }, + { + name: "Companies", + description: "Company management.", + }, + { + name: "Employees", + description: "Manage company employees.", + }, + { + name: "Service Orders", + description: "Manage company service orders.", + }, + { + name: "Uploads", + description: "Pre-signed uploads to Cloudflare R2.", + }, + ], + security: [], + components: { + securitySchemes: { + JWT: { + type: "http", + scheme: "bearer", + bearerFormat: "Bearer", + }, + }, + }, + }, + transform: jsonSchemaTransform, + transformObject: jsonSchemaTransformObject, + }); - request.log.error(error, "Unhandled error reached global error handler"); + await server.register(fastifyJwt, { + secret: env.JWT_SECRET, + cookie: { + cookieName: cookieKey("session"), + signed: false, + }, + }); - return response.status(500).send( - apiResponse({ - status: 500, - error: "Internal Server Error", - code: "internal_error", - message: error instanceof Error ? error.message : "Something went wrong.", - data: { - ...(error instanceof Error ? { message: error.message } : {}), - ...(error instanceof Error && error.stack - ? { stack: error.stack.split("\n").slice(0, 4).join("\n") } - : {}), - ...(error && typeof error === "object" - ? { details: String(error) } - : {}), - }, - }) + await server.register(fastifyCookie, { + secret: env.COOKIE_ENCRYPTION_SECRET, + }); + + await server.register(fastifyStatic, { + root: join(cwd(), "public"), + prefix: "/public/", + }); + + await server.register(authRoutes, { + prefix: "/auth", + }); + + await server.register(accountRoutes, { + prefix: "/account", + }); + + await server.register(credentialsRoutes, { + prefix: "/credentials", + }); + + await server.register(companiesRoutes, { + prefix: "/companies", + }); + + await server.register(employeesRoutes, { + prefix: "/companies/:subdomain/employees", + }); + + await server.register(serviceOrdersRoutes, { + prefix: "/companies/:subdomain/service-orders", + }); + + await server.register(uploadsRoutes, { + prefix: "/uploads", + }); + + server.get("/", { schema: { hide: true } }, (_, reply) => { + reply + .status(200) + .send("Hello from Fixr API! Reach the documentation at /docs"); + }); + + // OpenAPI spec consumed by Scalar (and external tools). + server.get("/openapi.json", { schema: { hide: true } }, async () => + server.swagger() ); -}); -//Run server. -server - .listen({ - port: Number(env.NODE_PORT), - host: "::", - }) - .then(() => { + // Scalar UI — register after all routes so the spec is complete. + await server.register(scalarUi, { + routePrefix: "/docs", + configuration: { + url: "/openapi.json", + metaData: { + title: `Docs - ${APP_NAME} API`, + }, + favicon: "/public/favicon.ico", + theme: "none", + }, + }); + + // Classic Swagger UI (alternative to Scalar). + await server.register(fastifySwaggerUi, { + routePrefix: "/reference", + }); + + await server.register(fastifyCors, { + origin: [env.FRONTEND_URL, `http://localhost:${env.NODE_PORT}`], + credentials: true, + }); +} + +registerPlugins() + .then(async () => { + await server.listen({ + port: Number(env.NODE_PORT), + host: "0.0.0.0", + }); + console.log( chalk.greenBright(`✔ Server running at http://localhost:${env.NODE_PORT}`) ); + console.log( + chalk.greenBright( + `✔ API docs (Scalar): http://localhost:${env.NODE_PORT}/docs` + ) + ); + console.log( + chalk.greenBright( + `✔ API docs (Swagger): http://localhost:${env.NODE_PORT}/reference` + ) + ); + }) + .catch((error) => { + console.error(error); + process.exit(1); }); export default server; diff --git a/apps/web/app/(public)/page.tsx b/apps/web/app/(public)/page.tsx index 226f67b..6fc7a1b 100644 --- a/apps/web/app/(public)/page.tsx +++ b/apps/web/app/(public)/page.tsx @@ -7,7 +7,7 @@ export default function Home() { return (
- +
); } diff --git a/apps/web/components/home/sections/features.tsx b/apps/web/components/home/sections/features.tsx index bf938f2..e71ee62 100644 --- a/apps/web/components/home/sections/features.tsx +++ b/apps/web/components/home/sections/features.tsx @@ -11,7 +11,7 @@ export default function Features({ className }: { className?: string }) { )} id="features" > - +
diff --git a/apps/web/components/home/sections/hero/app-preview.tsx b/apps/web/components/home/sections/hero/app-preview.tsx index a2bd948..c366378 100644 --- a/apps/web/components/home/sections/hero/app-preview.tsx +++ b/apps/web/components/home/sections/hero/app-preview.tsx @@ -13,30 +13,30 @@ export const AppPreview = () => { whileInView={{ y: 0, opacity: 1 }} >
+
diff --git a/apps/web/public/home_dark.png b/apps/web/public/home_dark.png index 493824d..96ac8f9 100644 Binary files a/apps/web/public/home_dark.png and b/apps/web/public/home_dark.png differ diff --git a/apps/web/public/home_dark.webp b/apps/web/public/home_dark.webp index 9f5b1e0..7261478 100644 Binary files a/apps/web/public/home_dark.webp and b/apps/web/public/home_dark.webp differ diff --git a/apps/web/public/home_light.png b/apps/web/public/home_light.png index 4308935..f96923d 100644 Binary files a/apps/web/public/home_light.png and b/apps/web/public/home_light.png differ diff --git a/apps/web/public/home_light.webp b/apps/web/public/home_light.webp index bc8b0f5..ddecb51 100644 Binary files a/apps/web/public/home_light.webp and b/apps/web/public/home_light.webp differ diff --git a/bun.lock b/bun.lock index d5072b0..bcdda38 100644 --- a/bun.lock +++ b/bun.lock @@ -118,6 +118,8 @@ "name": "server", "version": "1.0.0", "dependencies": { + "@aws-sdk/client-s3": "^3.1051.0", + "@aws-sdk/s3-request-presigner": "^3.1051.0", "@clerk/backend": "^3.4.12", "@fastify/cookie": "^11.0.1", "@fastify/cors": "^10.0.1", @@ -540,6 +542,8 @@ "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "tslib": "^2.6.2" } }, "sha512-FQLLmG0RHPleDCQAkl10LNH5L+FDOcUKwnRHRhfH0NHhleo46j9u1q0UHlVbKYOBfB0LjQMQgioxrWvLqEZ5Bg=="], + "@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.1054.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/signature-v4-multi-region": "^3.996.29", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-rgSDc0LwzM1yUL/UouFLR72HV5lhgdQRQlp8WWWot+q3nhBYrj+mklreWh28q9bGkgrNGWrcpMRp4Y3rC4sxVw=="], + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.29", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.2", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-Few9FoQqOt/0KSvZYP+qdW0dfOhfQ9N+gl2UUDvCPW6mkPKHli9LMbKxWj+wZ5zKPaOoqxuR3Hhy3OTpndkfSw=="], "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1054.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.14", "@aws-sdk/nested-clients": "^3.997.12", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hG9YKApmZOw+drJ9Nuoaf/OvC8e5W1+3eoLeN5p2uVCZRWsv27teIS0b4kiH6Sfv3WMmamqYJxmE2WMwyp/L/A=="], @@ -644,13 +648,13 @@ "@clerk/backend": ["@clerk/backend@3.4.13", "", { "dependencies": { "@clerk/shared": "^4.13.1", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-3BABnKE1YZpQqJ/S8QD5FpzE7jq+mgaMrO7rLiWTI8Bfs/xk11XYSp1lMEf3BTo/rzEtaUsXaVDGvccYogKapg=="], - "@clerk/clerk-react": ["@clerk/clerk-react@5.61.7", "", { "dependencies": { "@clerk/shared": "^3.47.6", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "sha512-w2V01rQiQdZHmnvSD8TjHoPq2lXQ9Ft2sT+Zu9ZQXzhyKF02ZFKENUFG40rkxmFViZXfcLhzLauiEbh42jBd6g=="], + "@clerk/clerk-react": ["@clerk/clerk-react@5.61.8", "", { "dependencies": { "@clerk/shared": "^3.47.7", "tslib": "2.8.1" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "sha512-n+k3q3xeyDkIPGTVA1J4Pd0+6MbS9Ia04qNlecOztTHwFfcirO5hy4TpOXrpGnO+GzYBuUMp7pYc3//ybMdEfg=="], - "@clerk/nextjs": ["@clerk/nextjs@6.39.4", "", { "dependencies": { "@clerk/backend": "^2.33.4", "@clerk/clerk-react": "^5.61.7", "@clerk/shared": "^3.47.6", "@clerk/types": "^4.101.24", "server-only": "0.0.1", "tslib": "2.8.1" }, "peerDependencies": { "next": "^13.5.7 || ^14.2.25 || ^15.2.3 || ^16", "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "sha512-bGImOTf6OBAUqKVJ3UIXNhKwetT0G68k96If9u/nLXroimsjfGAxZzx7MaW/xUYTunfaCKtM36tPO1MKVW2YLg=="], + "@clerk/nextjs": ["@clerk/nextjs@6.39.5", "", { "dependencies": { "@clerk/backend": "^2.33.5", "@clerk/clerk-react": "^5.61.8", "@clerk/shared": "^3.47.7", "@clerk/types": "^4.101.25", "server-only": "0.0.1", "tslib": "2.8.1" }, "peerDependencies": { "next": "^13.5.7 || ^14.2.25 || ^15.2.3 || ^16", "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" } }, "sha512-hvdvpiuHXPhlx3iaNfoXO1joZkNP4Lzw83teUNPrzsbOX0rT9QE0uSxS2J/UEAeqoPK6JhNK7dZGvZ9knsB/mg=="], - "@clerk/shared": ["@clerk/shared@3.47.6", "", { "dependencies": { "csstype": "3.1.3", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.7", "std-env": "^3.9.0", "swr": "2.3.4" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-hg2UFiwmSb3FnAciMxZZZculRN08NrlajXbBhT+nylMG6ljZoic0OlIGs+Rtp49scVMkX3Ytz5EUUj9pgVvcWQ=="], + "@clerk/shared": ["@clerk/shared@3.47.7", "", { "dependencies": { "csstype": "3.1.3", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.7", "std-env": "^3.9.0", "swr": "2.3.4" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-9Yv4MJFEaC7BzV0whxa4txQ4SoMu/3j1LBnI85EBykb5CcfXxIKvNX/9sjMUUySHlTOjsj7XZa5i3W5Dx02K/Q=="], - "@clerk/types": ["@clerk/types@4.101.24", "", { "dependencies": { "@clerk/shared": "^3.47.6" } }, "sha512-QMxfO7kGng2sJyUTwejRFSxTbVqJXVbr9rkI5Ow8sLEa+HHc+33D9KVJDjMQeJ6XIxwOk7d86X9gLnGFHxnncg=="], + "@clerk/types": ["@clerk/types@4.101.25", "", { "dependencies": { "@clerk/shared": "^3.47.7" } }, "sha512-gPxm3hlBkP7B9EfKyp3/UDonNOjg7Z0UvqfrMj5u8gA8nyzvC1UFYtSTTmTfgSY82+5Yo38YV0DYu9vNf6t9CQ=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], @@ -3458,6 +3462,8 @@ "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.7.3", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.7", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg=="], + "@babel/core/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/generator/@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], @@ -3476,7 +3482,7 @@ "@clerk/backend/@clerk/shared": ["@clerk/shared@4.13.1", "", { "dependencies": { "@tanstack/query-core": "^5.100.6", "dequal": "2.0.3", "glob-to-regexp": "0.4.1", "js-cookie": "3.0.7", "std-env": "^3.9.0" }, "peerDependencies": { "react": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0", "react-dom": "^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-DyUtvNHgMmqjtTM0q285jKaAXUmCDSyItiGQTt1dNL0M6DZ3bxqsJz7wXPjh9zezmU4BAnLpwhj5gsM3OuNPzA=="], - "@clerk/nextjs/@clerk/backend": ["@clerk/backend@2.33.4", "", { "dependencies": { "@clerk/shared": "^3.47.6", "@clerk/types": "^4.101.24", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-aF5IZGxicN6I5tq1g8GFTS3wbSVGzCRQ4zyZmZvRrux1xre2pVyWRoZ1qvMOjQ7doWJtEndxTZPjUaxXa4Zr2Q=="], + "@clerk/nextjs/@clerk/backend": ["@clerk/backend@2.33.5", "", { "dependencies": { "@clerk/shared": "^3.47.7", "@clerk/types": "^4.101.25", "standardwebhooks": "^1.0.0", "tslib": "2.8.1" } }, "sha512-YOzUYJfb1d4w+0rKKm+LnnNpkJGQ+NI/g7qmF3mgaSN9X9huteuwCZyufdsI7z2DDkwy/yGRgb9eUWV96t7xLg=="], "@clerk/shared/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], @@ -3836,7 +3842,7 @@ "trpc-cli/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "tsup/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + "tsup/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], "tsup/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], @@ -4348,57 +4354,57 @@ "string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "tsup/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + "tsup/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], - "tsup/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + "tsup/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], - "tsup/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + "tsup/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], - "tsup/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + "tsup/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], - "tsup/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + "tsup/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], - "tsup/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + "tsup/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], - "tsup/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + "tsup/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], - "tsup/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + "tsup/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], - "tsup/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + "tsup/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], - "tsup/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + "tsup/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], - "tsup/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + "tsup/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], - "tsup/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + "tsup/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], - "tsup/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + "tsup/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], - "tsup/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + "tsup/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], - "tsup/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + "tsup/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], - "tsup/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + "tsup/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], - "tsup/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + "tsup/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], - "tsup/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + "tsup/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], - "tsup/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + "tsup/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], - "tsup/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + "tsup/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], - "tsup/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + "tsup/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], - "tsup/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + "tsup/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], - "tsup/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + "tsup/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], - "tsup/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + "tsup/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], - "tsup/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + "tsup/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], - "tsup/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + "tsup/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], "type-is/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index bd98b02..4cbaa08 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,6 +1,11 @@ export * from "./clients"; export * from "./companies"; export * from "./employees"; +export * from "./model-categories"; +export * from "./model-makers"; export * from "./one-time-tokens"; export * from "./refresh-tokens"; +export * from "./service-order-photos"; +export * from "./service-orders"; +export * from "./uploads"; export * from "./users"; diff --git a/packages/db/src/schema/model-categories.ts b/packages/db/src/schema/model-categories.ts new file mode 100644 index 0000000..9037fa4 --- /dev/null +++ b/packages/db/src/schema/model-categories.ts @@ -0,0 +1,17 @@ +import { createId } from "@paralleldrive/cuid2"; +import { mysqlTable, varchar } from "drizzle-orm/mysql-core"; +import { createSelectSchema } from "drizzle-zod"; + +export const modelCategories = mysqlTable("model_categories", { + id: varchar("id", { length: 25 }) + .$defaultFn(() => createId()) + .primaryKey(), + name: varchar("name", { length: 100 }).notNull(), +}); + +export const modelCategorySelectSchema = createSelectSchema(modelCategories); + +/** @deprecated Renamed to {@link modelCategories} */ +export const deviceCategories = modelCategories; +/** @deprecated Renamed to {@link modelCategorySelectSchema} */ +export const deviceCategorySelectSchema = modelCategorySelectSchema; diff --git a/packages/db/src/schema/model-makers.ts b/packages/db/src/schema/model-makers.ts new file mode 100644 index 0000000..eb30ca0 --- /dev/null +++ b/packages/db/src/schema/model-makers.ts @@ -0,0 +1,17 @@ +import { createId } from "@paralleldrive/cuid2"; +import { mysqlTable, varchar } from "drizzle-orm/mysql-core"; +import { createSelectSchema } from "drizzle-zod"; + +export const modelMakers = mysqlTable("model_makers", { + id: varchar("id", { length: 25 }) + .$defaultFn(() => createId()) + .primaryKey(), + name: varchar("name", { length: 100 }).notNull(), +}); + +export const modelMakerSelectSchema = createSelectSchema(modelMakers); + +/** @deprecated Renamed to {@link modelMakers} */ +export const deviceBrands = modelMakers; +/** @deprecated Renamed to {@link modelMakerSelectSchema} */ +export const deviceBrandSelectSchema = modelMakerSelectSchema; diff --git a/packages/db/src/schema/service-order-photos.ts b/packages/db/src/schema/service-order-photos.ts new file mode 100644 index 0000000..42f2984 --- /dev/null +++ b/packages/db/src/schema/service-order-photos.ts @@ -0,0 +1,40 @@ +import { createId } from "@paralleldrive/cuid2"; +import { int, mysqlTable, timestamp, varchar } from "drizzle-orm/mysql-core"; +import { createSelectSchema } from "drizzle-zod"; +import { z } from "zod"; +import { employees } from "./employees"; +import { serviceOrders } from "./service-orders"; +import { uploads } from "./uploads"; + +export const serviceOrderImages = mysqlTable("service_order_images", { + id: varchar("id", { length: 25 }) + .$defaultFn(() => createId()) + .primaryKey(), + serviceOrderId: varchar("service_order_id", { length: 25 }) + .references(() => serviceOrders.id, { onDelete: "cascade" }) + .notNull(), + employeeId: varchar("employee_id", { length: 25 }) + .references(() => employees.id, { onDelete: "restrict" }) + .notNull(), + uploadId: varchar("upload_id", { length: 25 }) + .references(() => uploads.id, { onDelete: "restrict" }) + .notNull(), + imageUrl: varchar("image_url", { length: 255 }).notNull(), + fileName: varchar("file_name", { length: 255 }).notNull(), + sizeInBytes: int("size_in_bytes").notNull(), + contentType: varchar("content_type", { length: 50 }).notNull(), + description: varchar("description", { length: 255 }), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const serviceOrderImageSelectSchema = createSelectSchema( + serviceOrderImages, + { + createdAt: z.coerce.date(), + } +); + +/** @deprecated Renamed to {@link serviceOrderImages} */ +export const serviceOrderPhotos = serviceOrderImages; +/** @deprecated Renamed to {@link serviceOrderImageSelectSchema} */ +export const serviceOrderPhotoSelectSchema = serviceOrderImageSelectSchema; diff --git a/packages/db/src/schema/service-orders.ts b/packages/db/src/schema/service-orders.ts new file mode 100644 index 0000000..985453f --- /dev/null +++ b/packages/db/src/schema/service-orders.ts @@ -0,0 +1,61 @@ +import { createId } from "@paralleldrive/cuid2"; +import { + mysqlEnum, + mysqlTable, + text, + timestamp, + varchar, +} from "drizzle-orm/mysql-core"; +import { createSelectSchema } from "drizzle-zod"; +import { z } from "zod"; +import { clients } from "./clients"; +import { companies } from "./companies"; +import { employees } from "./employees"; +import { modelCategories } from "./model-categories"; +import { modelMakers } from "./model-makers"; + +export const serviceOrderStatusEnum = mysqlEnum("status", [ + "pending", + "diagnosing", + "waiting_approval", + "approved", + "fixing", + "ready", + "delivered", +]); + +export const serviceOrders = mysqlTable("service_orders", { + id: varchar("id", { length: 25 }) + .$defaultFn(() => createId()) + .primaryKey(), + companyId: varchar("company_id", { length: 25 }) + .references(() => companies.id, { onDelete: "cascade" }) + .notNull(), + clientId: varchar("client_id", { length: 25 }) + .references(() => clients.id, { onDelete: "restrict" }) + .notNull(), + employeeId: varchar("employee_id", { length: 25 }) + .references(() => employees.id, { onDelete: "restrict" }) + .notNull(), + deviceMakerId: varchar("device_brand_id", { length: 25 }) + .references(() => modelMakers.id, { onDelete: "restrict" }) + .notNull(), + deviceCategoryId: varchar("device_category_id", { length: 25 }) + .references(() => modelCategories.id, { onDelete: "restrict" }) + .notNull(), + deviceModel: varchar("device_model", { length: 100 }).notNull(), + imei: varchar("imei", { length: 50 }), + reportedDefect: text("reported_defect").notNull(), + observations: text("observations"), + status: serviceOrderStatusEnum.default("pending").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .notNull() + .$onUpdate(() => new Date()), +}); + +export const serviceOrderSelectSchema = createSelectSchema(serviceOrders, { + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); diff --git a/packages/db/src/schema/uploads.ts b/packages/db/src/schema/uploads.ts new file mode 100644 index 0000000..db4a14f --- /dev/null +++ b/packages/db/src/schema/uploads.ts @@ -0,0 +1,29 @@ +import { createId } from "@paralleldrive/cuid2"; +import { int, mysqlTable, timestamp, varchar } from "drizzle-orm/mysql-core"; +import { createSelectSchema } from "drizzle-zod"; +import { z } from "zod"; +import { companies } from "./companies"; +import { employees } from "./employees"; + +export const uploads = mysqlTable("uploads", { + id: varchar("id", { length: 25 }) + .$defaultFn(() => createId()) + .primaryKey(), + companyId: varchar("company_id", { length: 25 }) + .references(() => companies.id, { onDelete: "cascade" }) + .notNull(), + employeeId: varchar("employee_id", { length: 25 }) + .references(() => employees.id, { onDelete: "restrict" }) + .notNull(), + key: varchar("key", { length: 512 }).notNull(), + url: varchar("url", { length: 512 }).notNull(), + fileName: varchar("file_name", { length: 255 }).notNull(), + contentType: varchar("content_type", { length: 50 }).notNull(), + sizeInBytes: int("size_in_bytes").notNull(), + status: varchar("status", { length: 20 }).notNull().default("pending"), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const uploadSelectSchema = createSelectSchema(uploads, { + createdAt: z.coerce.date(), +}); diff --git a/packages/env/src/server.ts b/packages/env/src/server.ts index 3f7e469..52c949e 100644 --- a/packages/env/src/server.ts +++ b/packages/env/src/server.ts @@ -40,6 +40,32 @@ export const env = createEnv({ .string() .min(1) .describe("Cloudflare Turnstile secret key"), + R2_ACCESS_KEY_ID: z.string().min(1).describe("Cloudflare R2 access key ID"), + R2_SECRET_ACCESS_KEY: z + .string() + .min(1) + .describe("Cloudflare R2 secret access key"), + R2_BUCKET_URL: z + .url() + .describe( + "R2 S3 API URL including bucket path (e.g. https://.r2.cloudflarestorage.com/)" + ), + R2_PUBLIC_BASE_URL: z + .url() + .describe( + "Public base URL for uploaded objects (R2 public bucket or custom domain, no trailing slash)" + ), + R2_REGION: z + .string() + .default("auto") + .describe("R2 region (use auto for Cloudflare)"), + R2_PRESIGN_EXPIRES_IN: z.coerce + .number() + .int() + .min(60) + .max(3600) + .default(600) + .describe("Pre-signed upload URL TTL in seconds"), }, runtimeEnv: process.env, emptyStringAsUndefined: true, diff --git a/packages/schemas/package.json b/packages/schemas/package.json index 6f3a0ef..b96c951 100644 --- a/packages/schemas/package.json +++ b/packages/schemas/package.json @@ -53,6 +53,10 @@ "./service-orders": { "types": "./src/service-orders.ts", "default": "./src/service-orders.ts" + }, + "./uploads": { + "types": "./src/uploads.ts", + "default": "./src/uploads.ts" } }, "devDependencies": { diff --git a/packages/schemas/src/service-orders.ts b/packages/schemas/src/service-orders.ts index d18acff..ca7275a 100644 --- a/packages/schemas/src/service-orders.ts +++ b/packages/schemas/src/service-orders.ts @@ -1,5 +1,90 @@ import { formattedIMEI } from "@fixr/schemas/common"; import { z } from "zod"; +import { getPaginatedDataSchema } from "./utils"; + +export const serviceOrderStatuses = z.enum([ + "pending", + "diagnosing", + "waiting_approval", + "approved", + "fixing", + "ready", + "delivered", +]); + +export const createServiceOrderPhotoSchema = z.object({ + uploadId: z + .string({ error: "ID do upload é obrigatório." }) + .min(1, { message: "ID do upload é obrigatório." }), + description: z + .string() + .max(255, { message: "Descrição excede 255 caracteres." }) + .optional() + .nullable(), +}); + +export const createServiceOrderMockSchema = z.object({ + clientId: z.string().cuid2({ message: "Cliente inválido." }), + deviceBrandId: z + .string() + .cuid2({ message: "Marca do dispositivo inválida." }), + deviceCategoryId: z + .string() + .cuid2({ message: "Categoria do dispositivo inválida." }), + deviceModel: z + .string({ error: "Modelo do dispositivo é obrigatório." }) + .min(1, { message: "Modelo do dispositivo é obrigatório." }) + .max(100, { message: "Modelo do dispositivo excede 100 caracteres." }), + imei: z + .string() + .max(50, { message: "IMEI excede 50 caracteres." }) + .optional() + .nullable(), + reportedDefect: z + .string({ error: "Defeito relatado é obrigatório." }) + .min(1, { message: "Defeito relatado é obrigatório." }) + .max(65_535, { message: "Defeito relatado excede o limite permitido." }), + observations: z + .string() + .max(65_535, { message: "Observações excedem o limite permitido." }) + .optional() + .nullable(), + photos: z + .array(createServiceOrderPhotoSchema) + .max(20, { message: "Máximo de 20 fotos por ordem de serviço." }) + .default([]), +}); + +/** @deprecated Use createServiceOrderSchema */ +export const createOrderServiceMockSchema = createServiceOrderMockSchema; + +export const getServiceOrdersQuerySchema = getPaginatedDataSchema + .extend({ + deviceCategoryId: z + .string() + .cuid2({ message: "Categoria do dispositivo inválida." }) + .optional(), + employeeId: z + .string() + .cuid2({ message: "Responsável inválido." }) + .optional(), + status: serviceOrderStatuses.optional(), + dateFrom: z.coerce.date({ message: "Data inicial inválida." }).optional(), + dateTo: z.coerce.date({ message: "Data final inválida." }).optional(), + }) + .refine( + (data) => { + if (data.dateFrom && data.dateTo) { + return data.dateFrom <= data.dateTo; + } + return true; + }, + { + message: "A data inicial deve ser anterior ou igual à data final.", + path: ["dateTo"], + } + ); + import { documentSchema } from "./documents"; export const createOrderServiceSchema = z.object({ diff --git a/packages/schemas/src/uploads.ts b/packages/schemas/src/uploads.ts new file mode 100644 index 0000000..a78343c --- /dev/null +++ b/packages/schemas/src/uploads.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +export const MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024; + +export const createUploadPresignSchema = z.object({ + fileName: z + .string({ error: "Nome do arquivo é obrigatório." }) + .min(1, { message: "Nome do arquivo é obrigatório." }) + .max(255, { message: "Nome do arquivo excede 255 caracteres." }), + contentType: z + .string({ error: "Tipo de conteúdo é obrigatório." }) + .min(1, { message: "Tipo de conteúdo é obrigatório." }) + .max(255, { message: "Tipo de conteúdo excede 255 caracteres." }) + .regex(/^[^/]+\/[^/]+$/, { + message: "Tipo de conteúdo deve ser um MIME válido.", + }), + size: z + .number({ error: "Tamanho do arquivo é obrigatório." }) + .int({ message: "Tamanho deve ser um número inteiro." }) + .positive({ message: "Tamanho deve ser maior que zero." }) + .max(MAX_UPLOAD_SIZE_BYTES, { + message: "Arquivo excede o limite de 10 MB.", + }), +}); + +export const uploadPresignResponseSchema = z.object({ + id: z.string(), + uploadUrl: z.string().url(), + key: z.string(), + url: z.string().url(), + expiresIn: z.number().int().positive(), +});