Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone) - User settings page with profile editor + notification preferences - Audit log API with filtering (entity, action, user, date range) - Audit log page with search, entity type, and action filters - Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id] - Client duplicates endpoint: GET /api/v1/clients/duplicates?name= - Replace settings and audit stub pages with real implementations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,5 @@
|
||||
import { AuditLogList } from '@/components/admin/audit/audit-log-list';
|
||||
|
||||
export default function AuditLogPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Audit Log</h1>
|
||||
<p className="text-muted-foreground">Review system activity and changes</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <AuditLogList />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { UserSettings } from '@/components/settings/user-settings';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Settings</h1>
|
||||
<p className="text-muted-foreground">Manage your account and port preferences</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12">
|
||||
<p className="text-lg font-medium text-muted-foreground">Coming in Layer 2</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This feature will be implemented in the next phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <UserSettings />;
|
||||
}
|
||||
|
||||
31
src/app/api/v1/admin/audit/route.ts
Normal file
31
src/app/api/v1/admin/audit/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery } from '@/lib/api/route-helpers';
|
||||
import { listAuditLogs } from '@/lib/services/audit.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
const auditQuerySchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
entityType: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
userId: z.string().optional(),
|
||||
entityId: z.string().optional(),
|
||||
dateFrom: z.string().optional(),
|
||||
dateTo: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('admin', 'view_audit_log', async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, auditQuerySchema);
|
||||
const result = await listAuditLogs(ctx.portId, query);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -3,7 +3,7 @@ import { NextResponse } from 'next/server';
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { updateBerthSchema } from '@/lib/validators/berths';
|
||||
import { getBerthById, updateBerth } from '@/lib/services/berths.service';
|
||||
import { getBerthById, updateBerth, deleteBerth } from '@/lib/services/berths.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// GET /api/v1/berths/[id]
|
||||
@@ -18,7 +18,7 @@ export const GET = withAuth(
|
||||
}),
|
||||
);
|
||||
|
||||
// PATCH /api/v1/berths/[id] — update berth fields (no DELETE — import-only)
|
||||
// PATCH /api/v1/berths/[id]
|
||||
export const PATCH = withAuth(
|
||||
withPermission('berths', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
@@ -35,3 +35,20 @@ export const PATCH = withAuth(
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// DELETE /api/v1/berths/[id]
|
||||
export const DELETE = withAuth(
|
||||
withPermission('berths', 'edit', async (_req, ctx, params) => {
|
||||
try {
|
||||
await deleteBerth(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery } from '@/lib/api/route-helpers';
|
||||
import { listBerthsSchema } from '@/lib/validators/berths';
|
||||
import { listBerths } from '@/lib/services/berths.service';
|
||||
import { parseBody, parseQuery } from '@/lib/api/route-helpers';
|
||||
import { listBerthsSchema, createBerthSchema } from '@/lib/validators/berths';
|
||||
import { listBerths, createBerth } from '@/lib/services/berths.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
// GET /api/v1/berths — list berths for the current port (no POST — import-only)
|
||||
export const GET = withAuth(
|
||||
withPermission('berths', 'view', async (req, ctx) => {
|
||||
try {
|
||||
@@ -34,3 +33,20 @@ export const GET = withAuth(
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('berths', 'edit', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createBerthSchema);
|
||||
const data = await createBerth(ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
23
src/app/api/v1/clients/duplicates/route.ts
Normal file
23
src/app/api/v1/clients/duplicates/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery } from '@/lib/api/route-helpers';
|
||||
import { findDuplicates } from '@/lib/services/clients.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
const duplicateQuerySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('clients', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const { name } = parseQuery(req, duplicateQuerySchema);
|
||||
const data = await findDuplicates(ctx.portId, name);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -1,5 +1,26 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { withAuth, type AuthContext } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { db } from '@/lib/db';
|
||||
import { userProfiles } from '@/lib/db/schema';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { z } from 'zod';
|
||||
|
||||
const updateProfileSchema = z.object({
|
||||
displayName: z.string().min(1).max(200).optional(),
|
||||
phone: z.string().nullable().optional(),
|
||||
avatarUrl: z.string().url().nullable().optional(),
|
||||
preferences: z
|
||||
.object({
|
||||
dark_mode: z.boolean().optional(),
|
||||
locale: z.string().optional(),
|
||||
timezone: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
||||
return NextResponse.json({
|
||||
@@ -13,3 +34,45 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const PATCH = withAuth(async (req, ctx: AuthContext) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateProfileSchema);
|
||||
|
||||
const profile = await db.query.userProfiles.findFirst({
|
||||
where: eq(userProfiles.userId, ctx.userId),
|
||||
});
|
||||
if (!profile) {
|
||||
return NextResponse.json({ error: 'Profile not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (body.displayName !== undefined) updates.displayName = body.displayName;
|
||||
if (body.phone !== undefined) updates.phone = body.phone;
|
||||
if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
|
||||
if (body.preferences !== undefined) {
|
||||
updates.preferences = {
|
||||
...((profile.preferences as Record<string, unknown>) ?? {}),
|
||||
...body.preferences,
|
||||
};
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(userProfiles)
|
||||
.set(updates)
|
||||
.where(eq(userProfiles.userId, ctx.userId))
|
||||
.returning();
|
||||
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
userId: updated!.userId,
|
||||
displayName: updated!.displayName,
|
||||
phone: updated!.phone,
|
||||
avatarUrl: updated!.avatarUrl,
|
||||
preferences: updated!.preferences,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user