Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,6 @@
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
}
},
"js/ts.tsdk.path": "node_modules/typescript/lib"
}
2 changes: 2 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions apps/server/src/config/r2.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down Expand Up @@ -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**
Expand Down
177 changes: 177 additions & 0 deletions apps/server/src/core/docs/service-orders.docs.ts
Original file line number Diff line number Diff line change
@@ -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,
};
47 changes: 47 additions & 0 deletions apps/server/src/core/docs/uploads.docs.ts
Original file line number Diff line number Diff line change
@@ -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,
};
4 changes: 4 additions & 0 deletions apps/server/src/core/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -12,7 +14,9 @@ export const errors = defineErrors({
...credentialsErrors,
...companiesErrors,
...employeesErrors,
...serviceOrdersErrors,
...tokensErrors,
...uploadsErrors,

INTERNAL_ERROR: {
code: "internal_error",
Expand Down
28 changes: 24 additions & 4 deletions apps/server/src/core/lib/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,23 +66,43 @@ export async function getPaginatedRecords<T = unknown>({

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);
}
Expand Down
Loading
Loading