From d22da94c8fb29f383f3261a568b0d0583dd0d8e4 Mon Sep 17 00:00:00 2001 From: rajashish147 Date: Mon, 6 Apr 2026 14:44:50 +0530 Subject: [PATCH] feat(auth): add authentication routes and JWT identity resolution; implement profile handling for users without linked employee rows --- src/modules/auth/auth.routes.ts | 46 +++++++++++++++++++++++ src/modules/profile/profile.controller.ts | 14 +++++++ src/modules/profile/profile.routes.ts | 35 ++++++++++------- src/modules/profile/profile.service.ts | 13 +++++-- src/routes/index.ts | 2 + src/utils/errors.ts | 23 ++++++++---- src/utils/response.ts | 4 +- 7 files changed, 109 insertions(+), 28 deletions(-) create mode 100644 src/modules/auth/auth.routes.ts diff --git a/src/modules/auth/auth.routes.ts b/src/modules/auth/auth.routes.ts new file mode 100644 index 0000000..4bb9f7e --- /dev/null +++ b/src/modules/auth/auth.routes.ts @@ -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 { + 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, + }, + }); + }, + ); +} diff --git a/src/modules/profile/profile.controller.ts b/src/modules/profile/profile.controller.ts index 8a7fb1f..5a28f26 100644 --- a/src/modules/profile/profile.controller.ts +++ b/src/modules/profile/profile.controller.ts @@ -6,6 +6,10 @@ 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, @@ -13,6 +17,16 @@ export const profileController = { ): Promise { 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"); diff --git a/src/modules/profile/profile.routes.ts b/src/modules/profile/profile.routes.ts index 173788b..e3f6052 100644 --- a/src/modules/profile/profile.routes.ts +++ b/src/modules/profile/profile.routes.ts @@ -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, -}); +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, + }), + 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. diff --git a/src/modules/profile/profile.service.ts b/src/modules/profile/profile.service.ts index 4c28005..9084fcd 100644 --- a/src/modules/profile/profile.service.ts +++ b/src/modules/profile/profile.service.ts @@ -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 { + async getMyProfile(request: FastifyRequest): Promise { 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); diff --git a/src/routes/index.ts b/src/routes/index.ts index 5c6f7a6..ae5f4fa 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -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 { 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); diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 36cba29..45ea987 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -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( diff --git a/src/utils/response.ts b/src/utils/response.ts index 440feab..e39fe67 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -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; }