Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
078743a
feat: add paginated dashboard activity feed
Banx17 May 28, 2026
23a38f7
feat: add downloadable PDF payslip generation API
shogun444 May 28, 2026
2ab265e
feat: implement global search API functionality
Oluwasuyi-Timilehin May 28, 2026
771e81c
Merge pull request #489 from Banx17/feature/474-paginated-recent-acti…
codeZe-us May 29, 2026
5bc9a85
Merge pull request #490 from shogun444/feat/Generate-PDF-payslipAPI
codeZe-us May 29, 2026
260451d
feat(finance): add dynamic sorting to transaction history endpoint
aabxtract May 29, 2026
50ec966
feat: scaffold payroll API and calculation endpoint
jannatfirdous07 May 29, 2026
1f3a2ad
Merge pull request #491 from Oluwasuyi-Timilehin/feature/476-global-s…
codeZe-us May 30, 2026
0eea3bc
Merge pull request #494 from codewithzubair07/feat/payroll-calculate-…
codeZe-us May 30, 2026
e606dc5
[FEATURE] Fetch Tax Jurisdiction Rates API (#479)
eischideraa-unn May 31, 2026
0a4842d
feat: implement Process Fiat Deposits API for immediate charging of p…
AdityaK9822 May 31, 2026
6fa5587
feat: implement unlink connected bank account API
AdityaK9822 May 31, 2026
1f19473
feat: implement unlink connected bank account API
AdityaK9822 May 31, 2026
ea7ee8b
Merge pull request #496 from eischideraa-unn/dev
codeZe-us May 31, 2026
fd4d9b2
Merge pull request #498 from aabxtract/feat/transaction-history-filte…
codeZe-us May 31, 2026
b9156c1
feat(notifications): add mark all as read API
Ghadaffijr May 31, 2026
74af5eb
feat: add payroll draft adjustments API
auracule007 May 31, 2026
9305bb8
test: fix failing api and auth checks
auracule007 May 31, 2026
40ba1d4
Merge pull request #497 from AdityaK9822/branch
codeZe-us Jun 1, 2026
be40e86
Merge pull request #499 from AdityaK9822/feat/unlink-bank-account
codeZe-us Jun 1, 2026
afba06b
Merge pull request #500 from Ghadaffijr/feat/472-mark-all-read-api
codeZe-us Jun 1, 2026
d77e0e0
Merge branch 'main' into payroll-adjustments-api-478
auracule007 Jun 1, 2026
cbc6975
Merge branch 'dev' into payroll-adjustments-api-478
auracule007 Jun 1, 2026
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
17 changes: 17 additions & 0 deletions drizzle/migrations/0001_add_payroll_drafts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
CREATE TYPE "payroll_draft_status" AS ENUM('active', 'processed', 'cancelled');
--> statement-breakpoint
CREATE TABLE "payroll_drafts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"status" "payroll_draft_status" DEFAULT 'active' NOT NULL,
"employees_payload" jsonb DEFAULT '[]'::jsonb NOT NULL,
"total_amount" integer DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "payroll_drafts" ADD CONSTRAINT "payroll_drafts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
CREATE INDEX "payroll_drafts_organization_id_idx" ON "payroll_drafts" ("organization_id");
--> statement-breakpoint
CREATE INDEX "payroll_drafts_status_idx" ON "payroll_drafts" ("status");
9 changes: 8 additions & 1 deletion drizzle/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
"when": 1776872929566,
"tag": "0000_perfect_ravenous",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1777132800000,
"tag": "0001_add_payroll_drafts",
"breakpoints": true
}
]
}
}
11 changes: 3 additions & 8 deletions src/app/api/v1/auth/2fa/verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ import { eq } from "drizzle-orm";
*/
export async function POST(req: NextRequest) {
try {

const body = await req.json();
const validatedData = VerifyTwoFactorSchema.parse(body);

Expand Down Expand Up @@ -77,9 +76,9 @@ export async function POST(req: NextRequest) {
return ApiResponse.error("User not found", 404);
}

const accessToken = AuthUtils.generateToken(user.id, user.email);
const accessToken = await AuthUtils.generateToken(user.id, user.email);

const refreshToken = AuthUtils.generateToken(user.id, user.email);
const refreshToken = await AuthUtils.generateToken(user.id, user.email);

return ApiResponse.success(
{
Expand All @@ -99,9 +98,7 @@ export async function POST(req: NextRequest) {
200,
);
} catch (error) {

if (error instanceof AppError) {

if (error.statusCode === 403 || error.statusCode === 429) {
try {
const body = await req.clone().json();
Expand All @@ -128,9 +125,7 @@ export async function POST(req: NextRequest) {
}
}
}
} catch {

}
} catch {}
}

return ApiResponse.error(error.message, error.statusCode, error.errors);
Expand Down
123 changes: 123 additions & 0 deletions src/app/api/v1/dashboard/activity/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
import { GET } from "./route";
import { AuthUtils } from "@/server/utils/auth";
import { ActivityService } from "@/server/services/activity.service";
import { UnauthorizedError, ForbiddenError } from "@/server/utils/errors";

vi.mock("@/server/utils/auth");
vi.mock("@/server/services/activity.service");

describe("GET /api/v1/dashboard/activity", () => {
beforeEach(() => {
vi.clearAllMocks();
});

const createMockRequest = (url = "http://localhost/api/v1/dashboard/activity") =>
new NextRequest(url);

it("should retrieve paginated recent activity with default pagination", async () => {
vi.mocked(AuthUtils.authenticateRequest).mockResolvedValue({
userId: "user-123",
} as any);
vi.mocked(ActivityService.listRecentActivities).mockResolvedValue({
data: [
{
id: "transaction:tx-001",
sourceId: "tx-001",
type: "transaction",
title: "Deposit transaction",
description: "Deposit via monnify",
amount: "250000",
status: "completed",
timestamp: "2026-05-28T00:00:00.000Z",
metadata: { provider: "monnify" },
},
],
meta: {
page: 1,
limit: 10,
total: 1,
totalPages: 1,
},
});

const response = await GET(createMockRequest());
const body = await response.json();

expect(response.status).toBe(200);
expect(body.success).toBe(true);
expect(body.message).toBe("Recent activities retrieved successfully");
expect(body.data.data).toHaveLength(1);
expect(body.data.meta.total).toBe(1);
expect(ActivityService.listRecentActivities).toHaveBeenCalledWith(
"user-123",
1,
10,
);
});

it("should pass explicit page and limit query parameters to the service", async () => {
vi.mocked(AuthUtils.authenticateRequest).mockResolvedValue({
userId: "user-123",
} as any);
vi.mocked(ActivityService.listRecentActivities).mockResolvedValue({
data: [],
meta: {
page: 2,
limit: 5,
total: 0,
totalPages: 0,
},
});

await GET(createMockRequest("http://localhost/api/v1/dashboard/activity?page=2&limit=5"));

expect(ActivityService.listRecentActivities).toHaveBeenCalledWith(
"user-123",
2,
5,
);
});

it("should return 400 for invalid pagination query parameters", async () => {
vi.mocked(AuthUtils.authenticateRequest).mockResolvedValue({
userId: "user-123",
} as any);

const response = await GET(
createMockRequest("http://localhost/api/v1/dashboard/activity?page=0&limit=101"),
);
const body = await response.json();

expect(response.status).toBe(400);
expect(body.success).toBe(false);
expect(body.message).toBe("Invalid query parameters");
expect(ActivityService.listRecentActivities).not.toHaveBeenCalled();
});

it("should return 401 for unauthorized access", async () => {
vi.mocked(AuthUtils.authenticateRequest).mockRejectedValue(
new UnauthorizedError(),
);

const response = await GET(createMockRequest());

expect(response.status).toBe(401);
});

it("should return 403 when the user has no organization", async () => {
vi.mocked(AuthUtils.authenticateRequest).mockResolvedValue({
userId: "user-123",
} as any);
vi.mocked(ActivityService.listRecentActivities).mockRejectedValue(
new ForbiddenError("User is not associated with any organization"),
);

const response = await GET(createMockRequest());
const body = await response.json();

expect(response.status).toBe(403);
expect(body.message).toBe("User is not associated with any organization");
});
});
84 changes: 84 additions & 0 deletions src/app/api/v1/dashboard/activity/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { ApiResponse } from "@/server/utils/api-response";
import { AppError } from "@/server/utils/errors";
import { AuthUtils } from "@/server/utils/auth";
import { ActivityService } from "@/server/services/activity.service";

const ActivityQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(10),
});

/**
* @swagger
* /dashboard/activity:
* get:
* summary: List recent dashboard activity
* description: Retrieve a chronologically ordered, paginated activity feed combining transactions, payroll runs, and created invoices for the authenticated user's organization.
* tags: [General]
* security:
* - bearerAuth: []
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* minimum: 1
* default: 1
* description: Page number
* - in: query
* name: limit
* schema:
* type: integer
* minimum: 1
* maximum: 100
* default: 10
* description: Number of activity items per page
* responses:
* 200:
* description: Recent activities retrieved successfully
* 400:
* description: Invalid query parameters
* 401:
* description: Unauthorized
* 403:
* description: User not associated with any organization
*/
export async function GET(req: NextRequest) {
try {
const { userId } = await AuthUtils.authenticateRequest(req);
const { searchParams } = new URL(req.url);

const parsed = ActivityQuerySchema.safeParse({
page: searchParams.get("page") ?? undefined,
limit: searchParams.get("limit") ?? undefined,
});

if (!parsed.success) {
return ApiResponse.error(
"Invalid query parameters",
400,
parsed.error.flatten().fieldErrors,
);
}

const activities = await ActivityService.listRecentActivities(
userId,
parsed.data.page,
parsed.data.limit,
);

return ApiResponse.success(
activities,
"Recent activities retrieved successfully",
);
} catch (error) {
if (error instanceof AppError) {
return ApiResponse.error(error.message, error.statusCode, error.errors);
}

console.error("[Recent Activity Error]", error);
return ApiResponse.error("Internal server error", 500);
}
}
53 changes: 53 additions & 0 deletions src/app/api/v1/dashboard/notifications/read-all/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { NextRequest } from "next/server";
import { ApiResponse } from "@/server/utils/api-response";
import { AppError } from "@/server/utils/errors";
import { AuthUtils } from "@/server/utils/auth";
import { markAllUnreadAsRead } from "@/server/services/notification.service";

/**
* @swagger
* /dashboard/notifications/read-all:
* patch:
* summary: Mark all notifications as read
* description: Marks all unread notifications as read for the authenticated user
* tags: [Notifications]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Notifications marked as read successfully
* content:
* application/json:
* schema:
* type: object
* properties:
* success:
* type: boolean
* message:
* type: string
* count:
* type: number
* 401:
* description: Unauthorized
* 500:
* description: Internal server error
*/
export async function PATCH(req: NextRequest) {
try {
const { userId } = await AuthUtils.authenticateRequest(req);

const updatedCount = await markAllUnreadAsRead(userId);

return ApiResponse.success(
{ count: updatedCount },
"Notifications marked as read"
);
} catch (error) {
if (error instanceof AppError) {
return ApiResponse.error(error.message, error.statusCode, error.errors);
}

console.error("[Notifications Read All Error]", error);
return ApiResponse.error("Internal server error", 500);
}
}
41 changes: 41 additions & 0 deletions src/app/api/v1/dashboard/search/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { NextRequest } from "next/server";
import { ApiResponse } from "@/server/utils/api-response";
import { AppError, BadRequestError } from "@/server/utils/errors";
import { AuthUtils } from "@/server/utils/auth";
import { SearchService } from "@/server/services/search.service";

export async function GET(req: NextRequest) {
try {
const { user } = await AuthUtils.authenticateRequest(req);
const query = req.nextUrl.searchParams.get("q")?.trim() ?? "";

if (!query) {
throw new BadRequestError("Query parameter 'q' is required");
}

if (!user.organizationId) {
return ApiResponse.success(
{
employees: [],
invoices: [],
transactions: [],
},
"Global search results retrieved successfully",
);
}

const result = await SearchService.globalSearch(user.organizationId, query);

return ApiResponse.success(
result,
"Global search results retrieved successfully",
);
} catch (error) {
if (error instanceof AppError) {
return ApiResponse.error(error.message, error.status, error.errors);
}

console.error("[Global Search Error]", error);
return ApiResponse.error("Internal server error", 500);
}
}
Loading
Loading