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
46 changes: 46 additions & 0 deletions src/modules/auth/auth.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { authenticate } from "../../middleware/auth.js";

const authMeResponseSchema = z.object({
success: z.literal(true),
data: z.object({
id: z.string().uuid(),
email: z.string().email().optional(),
role: z.enum(["ADMIN", "EMPLOYEE"]),
orgId: z.string().uuid(),
}),
});

/**
* Auth routes β€” identity resolution from JWT.
*
* /auth/me returns the authenticated user's claims directly from the verified
* JWT. No database query is performed. This endpoint always succeeds for any
* request that carries a valid token, decoupling identity from profile state.
*/
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.get(
"/auth/me",
{
schema: {
tags: ["auth"],
response: { 200: authMeResponseSchema.describe("Authenticated user identity") },
},
preValidation: [authenticate],
},
async (request, reply) => {
const { sub, email, role, organization_id } = request.user;

return reply.status(200).send({
success: true,
data: {
id: sub,
email,
role,
orgId: organization_id,
},
});
},
);
}
14 changes: 14 additions & 0 deletions src/modules/profile/profile.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,27 @@ export const profileController = {
/**
* GET /profile/me
* Employee's own profile.
*
* Returns 200 {data: null, meta: {hasProfile: false}} for ADMIN users and
* any auth'd user without a linked employee row. A missing profile is NOT
* an authentication or authorisation failure.
*/
async getMyProfile(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
try {
const data = await profileService.getMyProfile(request);

if (data === null) {
reply.status(200).send({
success: true,
data: null,
meta: { hasProfile: false },
});
return;
}

reply.status(200).send(ok(data));
} catch (error) {
handleError(error, request, reply, "Unexpected error in getMyProfile");
Expand Down
35 changes: 21 additions & 14 deletions src/modules/profile/profile.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,27 @@ const profileStatsSchema = z.object({
expensesApproved: z.number(),
});

const profileResponseSchema = z.object({
success: z.literal(true),
data: z.object({
id: z.string(),
name: z.string(),
employee_code: z.string().nullable(),
phone: z.string().nullable(),
is_active: z.boolean(),
activityStatus: z.enum(["ACTIVE", "RECENT", "INACTIVE"]),
last_activity_at: z.string().nullable(),
created_at: z.string(),
stats: profileStatsSchema,
}) satisfies z.ZodType<EmployeeProfileData>,
});
const profileResponseSchema = z.union([
z.object({
success: z.literal(true),
data: z.object({
id: z.string(),
name: z.string(),
employee_code: z.string().nullable(),
phone: z.string().nullable(),
is_active: z.boolean(),
activityStatus: z.enum(["ACTIVE", "RECENT", "INACTIVE"]),
last_activity_at: z.string().nullable(),
created_at: z.string(),
stats: profileStatsSchema,
}) satisfies z.ZodType<EmployeeProfileData>,
}),
z.object({
success: z.literal(true),
data: z.null(),
meta: z.object({ hasProfile: z.literal(false) }),
}),
]);

/**
* Profile routes β€” employee self-profile and admin employee profile lookup.
Expand Down
13 changes: 9 additions & 4 deletions src/modules/profile/profile.service.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import type { FastifyRequest } from "fastify";
import { profileRepository, computeActivityStatusFromTimestamp } from "./profile.repository.js";
import { NotFoundError, ForbiddenError } from "../../utils/errors.js";
import { NotFoundError } from "../../utils/errors.js";
import type { EmployeeProfileData } from "../../types/shared.js";

export const profileService = {
/**
* Get the requesting employee's own profile.
* Requires an employee context (request.employeeId).
*
* Returns null when the authenticated user has no linked employee row
* (valid state for ADMIN users). Callers must treat null as a 200
* "no profile" state β€” NOT as an authentication or authorisation failure.
*/
async getMyProfile(request: FastifyRequest): Promise<EmployeeProfileData> {
async getMyProfile(request: FastifyRequest): Promise<EmployeeProfileData | null> {
const employeeId = request.employeeId;
if (!employeeId) {
throw new ForbiddenError("No employee profile linked to this account");
// ADMIN users (and any auth'd user without an employee row) have no profile.
// This is semantically correct β€” missing profile β‰  auth failure.
return null;
}

return this.getEmployeeProfile(request, employeeId);
Expand Down
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import { adminMapRoutes } from "../modules/admin/map.routes.js";
import { webhookDlqRoutes } from "../modules/admin/webhook-dlq.routes.js";
import { eventsRoutes } from "./events.routes.js";
import { webhooksRoutes } from "../modules/webhooks/webhooks.routes.js";
import { authRoutes } from "../modules/auth/auth.routes.js";

export async function registerRoutes(app: FastifyInstance): Promise<void> {
await app.register(healthRoutes);
await app.register(internalRoutes);
await app.register(debugRoutes);
await app.register(authRoutes);
await app.register(attendanceRoutes);
await app.register(locationsRoutes);
await app.register(expensesRoutes);
Expand Down
23 changes: 15 additions & 8 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,33 +19,40 @@ export class AppError extends Error {
}

export class UnauthorizedError extends AppError {
constructor(message = "Unauthorized") {
super(message, 401);
constructor(message = "Unauthorized", code = "UNAUTHORIZED") {
super(message, 401, code);
this.name = "UnauthorizedError";
}
}

export class NotFoundError extends AppError {
constructor(message = "Resource not found") {
super(message, 404);
constructor(message = "Resource not found", code = "NOT_FOUND") {
super(message, 404, code);
this.name = "NotFoundError";
}
}

export class BadRequestError extends AppError {
constructor(message = "Bad request") {
super(message, 400);
constructor(message = "Bad request", code = "VALIDATION_ERROR") {
super(message, 400, code);
this.name = "BadRequestError";
}
}

export class ForbiddenError extends AppError {
constructor(message = "Forbidden") {
super(message, 403);
constructor(message = "Forbidden", code = "FORBIDDEN") {
super(message, 403, code);
this.name = "ForbiddenError";
}
}

export class ProfileNotFoundError extends NotFoundError {
constructor(message = "No employee profile linked") {
super(message, "PROFILE_NOT_FOUND");
this.name = "ProfileNotFoundError";
}
}

export class QueueOverloadedError extends AppError {
constructor(queueName: string, queueDepth: number, maxQueueDepth: number) {
super(
Expand Down
4 changes: 2 additions & 2 deletions src/utils/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ export function handleError(
const message = error.issues.map((i) => i.message).join("; ");
void reply
.status(400)
.send(fail(`Validation failed: ${message}`, request.id));
.send(fail(`Validation failed: ${message}`, request.id, "VALIDATION_ERROR"));
throw error;
}

request.log.error({ err: error }, context);
void reply.status(500).send(fail("Internal server error", request.id));
void reply.status(500).send(fail("Internal server error", request.id, "INTERNAL_ERROR"));
throw error;
}
Loading