This commit is contained in:
phaichayon
2026-06-11 12:46:57 +07:00
parent 1993d88306
commit 7a390cf0df
720 changed files with 100919 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { assignAsset, parseAssignmentPayload, requireAssetAccess } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetAssign);
const payload = parseAssignmentPayload(await request.json());
await assignAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset assigned successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to assign asset' }, { status: 500 });
}
}

View File

@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { getAssetMovements, requireAssetAccess } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const movements = await getAssetMovements(organization.id, id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Asset movements loaded successfully',
movements
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load asset movements' }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { parseRepairPayload, repairAsset, requireAssetAccess } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetRepair);
const payload = parseRepairPayload(await request.json());
await repairAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset repair recorded successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to record repair' }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { parseReturnPayload, requireAssetAccess, returnAsset } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetReturn);
const payload = parseReturnPayload(await request.json());
await returnAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset returned successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to return asset' }, { status: 500 });
}
}

View File

@@ -0,0 +1,88 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { assets } from '@/db/schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { db } from '@/lib/db';
import {
getAssetById,
getAssetMovements,
getAssetRepairs,
parseAssetPayload,
requireAssetAccess,
updateAsset
} from '../_lib';
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const [asset, movements, repairs] = await Promise.all([
getAssetById(organization.id, id),
getAssetMovements(organization.id, id),
getAssetRepairs(organization.id, id)
]);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Asset loaded successfully',
asset,
movements,
repairs
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load asset' }, { status: 500 });
}
}
export async function PUT(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetUpdate);
const payload = parseAssetPayload(await request.json());
await updateAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update asset' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireAssetAccess(PERMISSIONS.assetUpdate);
await db.delete(assets).where(and(eq(assets.id, id), eq(assets.organizationId, organization.id)));
return NextResponse.json({
success: true,
message: 'Asset deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to delete asset' }, { status: 500 });
}
}

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { parseTransferPayload, requireAssetAccess, transferAsset } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetTransfer);
const payload = parseTransferPayload(await request.json());
await transferAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset transferred successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to transfer asset' }, { status: 500 });
}
}

926
src/app/api/assets/_lib.ts Normal file
View File

@@ -0,0 +1,926 @@
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
import { z } from 'zod';
import {
assetAssignments,
assetMovements,
assetRepairs,
assets,
departments,
employees,
locations,
organizations,
sites,
users
} from '@/db/schema';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
import type {
Asset,
AssetAssignmentPayload,
AssetFilters,
AssetMutationPayload,
AssetOptionsResponse,
AssetRepair,
AssetRepairPayload,
AssetReturnPayload,
AssetTransferPayload,
AssetMovement
} from '@/features/assets/api/types';
const assetSchema = z.object({
assetCode: z.string().min(1),
assetName: z.string().min(1),
assetType: z.enum(['hardware', 'software']),
assetCategory: z.string().min(1),
brand: z.string().optional().or(z.literal('')),
model: z.string().optional().or(z.literal('')),
specification: z.string().optional().or(z.literal('')),
serialNumber: z.string().optional().or(z.literal('')),
siteId: z.string().optional().nullable(),
departmentId: z.string().optional().nullable(),
locationId: z.string().optional().nullable(),
currentEmployeeId: z.string().optional().nullable(),
custodianTeam: z.string().optional().or(z.literal('')),
purchaseDate: z.string().optional().nullable(),
warrantyExpiryDate: z.string().optional().nullable(),
eosDate: z.string().optional().nullable(),
eolDate: z.string().optional().nullable(),
eopDate: z.string().optional().nullable(),
status: z.enum(['AVAILABLE', 'ASSIGNED', 'IN_REPAIR', 'LOST', 'RETIRED']),
assetCondition: z.enum(['NORMAL', 'DAMAGED']),
dispositionStatus: z.enum([
'NONE',
'WAITING_REPAIR',
'WAITING_WRITE_OFF',
'WRITE_OFF_COMPLETED'
]),
notes: z.string().optional().or(z.literal(''))
});
const assignmentSchema = z.object({
employeeId: z.string().min(1),
departmentId: z.string().optional().nullable(),
locationId: z.string().optional().nullable(),
siteId: z.string().optional().nullable(),
assignDate: z.string().min(1),
documentAttachment: z.string().min(1),
reason: z.string().optional().or(z.literal(''))
});
const transferSchema = z.object({
newEmployeeId: z.string().optional().nullable(),
newDepartmentId: z.string().optional().nullable(),
newLocationId: z.string().optional().nullable(),
newSiteId: z.string().optional().nullable(),
newAssetCode: z.string().optional().or(z.literal('')),
transferDate: z.string().min(1),
referenceDocument: z.string().min(1),
reason: z.string().optional().or(z.literal(''))
});
const returnSchema = z.object({
returnDate: z.string().min(1),
assetCondition: z.enum(['NORMAL', 'DAMAGED']),
remark: z.string().min(1)
});
const repairSchema = z.object({
repairDate: z.string().optional().nullable(),
vendor: z.string().optional().or(z.literal('')),
problem: z.string().min(1),
resolution: z.string().optional().or(z.literal('')),
cost: z.number().nullable().optional(),
markAsRepair: z.boolean().optional().default(true)
});
function asDate(value?: string | null) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function iso(value: Date | string | null) {
if (!value) {
return null;
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function sanitizeText(value?: string | null) {
return value && value.trim().length > 0 ? value.trim() : null;
}
type AssetRow = {
id: string;
organizationId: string;
assetUid: string;
assetCode: string;
assetName: string;
companyName: string | null;
assetType: string;
assetCategory: string;
brand: string | null;
model: string | null;
specification: string | null;
serialNumber: string | null;
siteId: string | null;
siteName: string | null;
departmentId: string | null;
departmentName: string | null;
locationId: string | null;
locationName: string | null;
currentEmployeeId: string | null;
currentEmployeeName: string | null;
custodianTeam: string | null;
purchaseDate: Date | null;
warrantyExpiryDate: Date | null;
eosDate: Date | null;
eolDate: Date | null;
eopDate: Date | null;
status: string;
assetCondition: string;
dispositionStatus: string;
notes: string | null;
createdAt: Date;
updatedAt: Date;
};
function formatAsset(row: AssetRow): Asset {
return {
id: row.id,
organizationId: row.organizationId,
assetUid: row.assetUid,
assetCode: row.assetCode,
assetName: row.assetName,
companyName: row.companyName,
assetType: row.assetType === 'software' ? 'software' : 'hardware',
assetCategory: row.assetCategory,
brand: row.brand,
model: row.model,
specification: row.specification,
serialNumber: row.serialNumber,
siteId: row.siteId,
siteName: row.siteName,
departmentId: row.departmentId,
departmentName: row.departmentName,
locationId: row.locationId,
locationName: row.locationName,
currentEmployeeId: row.currentEmployeeId,
currentEmployeeName: row.currentEmployeeName,
custodianTeam: row.custodianTeam,
purchaseDate: iso(row.purchaseDate),
warrantyExpiryDate: iso(row.warrantyExpiryDate),
eosDate: iso(row.eosDate),
eolDate: iso(row.eolDate),
eopDate: iso(row.eopDate),
status: (['AVAILABLE', 'ASSIGNED', 'IN_REPAIR', 'LOST', 'RETIRED'] as const).includes(
row.status as Asset['status']
)
? (row.status as Asset['status'])
: 'AVAILABLE',
assetCondition: (['NORMAL', 'DAMAGED'] as const).includes(row.assetCondition as Asset['assetCondition'])
? (row.assetCondition as Asset['assetCondition'])
: 'NORMAL',
dispositionStatus: (
['NONE', 'WAITING_REPAIR', 'WAITING_WRITE_OFF', 'WRITE_OFF_COMPLETED'] as const
).includes(row.dispositionStatus as Asset['dispositionStatus'])
? (row.dispositionStatus as Asset['dispositionStatus'])
: 'NONE',
notes: row.notes,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString()
};
}
export async function requireAssetAccess(permission: string) {
return requireOrganizationAccess({ permission });
}
export function parseAssetPayload(body: unknown): AssetMutationPayload {
const parsed = assetSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid asset payload');
}
return parsed.data;
}
export function parseAssignmentPayload(body: unknown): AssetAssignmentPayload {
const parsed = assignmentSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid assignment payload');
}
return parsed.data;
}
export function parseTransferPayload(body: unknown): AssetTransferPayload {
const parsed = transferSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid transfer payload');
}
return parsed.data;
}
export function parseReturnPayload(body: unknown): AssetReturnPayload {
const parsed = returnSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid return payload');
}
return parsed.data;
}
export function parseRepairPayload(body: unknown): AssetRepairPayload {
const parsed = repairSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid repair payload');
}
return parsed.data;
}
async function getBaseAssetRows(organizationId: string) {
const rows = await db
.select({
id: assets.id,
organizationId: assets.organizationId,
assetUid: assets.assetUid,
assetCode: assets.assetCode,
assetName: assets.assetName,
companyName: organizations.name,
assetType: assets.assetType,
assetCategory: assets.assetCategory,
brand: assets.brand,
model: assets.model,
specification: assets.specification,
serialNumber: assets.serialNumber,
siteId: assets.siteId,
siteName: sites.name,
departmentId: assets.departmentId,
departmentName: departments.name,
locationId: assets.locationId,
locationName: locations.name,
currentEmployeeId: assets.currentEmployeeId,
currentEmployeeName: employees.name,
custodianTeam: assets.custodianTeam,
purchaseDate: assets.purchaseDate,
warrantyExpiryDate: assets.warrantyExpiryDate,
eosDate: assets.eosDate,
eolDate: assets.eolDate,
eopDate: assets.eopDate,
status: assets.status,
assetCondition: assets.assetCondition,
dispositionStatus: assets.dispositionStatus,
notes: assets.notes,
createdAt: assets.createdAt,
updatedAt: assets.updatedAt
})
.from(assets)
.innerJoin(organizations, eq(organizations.id, assets.organizationId))
.leftJoin(sites, eq(sites.id, assets.siteId))
.leftJoin(departments, eq(departments.id, assets.departmentId))
.leftJoin(locations, eq(locations.id, assets.locationId))
.leftJoin(employees, eq(employees.id, assets.currentEmployeeId))
.where(eq(assets.organizationId, organizationId));
return rows as AssetRow[];
}
export async function listAssetsForOrganization(organizationId: string, filters: AssetFilters) {
let rows = await getBaseAssetRows(organizationId);
if (filters.search) {
const search = filters.search.toLowerCase();
rows = rows.filter((row) =>
[
row.assetUid,
row.assetCode,
row.assetName,
row.assetCategory,
row.companyName,
row.brand,
row.model,
row.serialNumber
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(search))
);
}
if (filters.status) {
const selected = filters.status
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
rows = rows.filter((row) => selected.includes(row.status));
}
if (filters.assetType) {
const selected = filters.assetType
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
rows = rows.filter((row) => selected.includes(row.assetType));
}
if (filters.assetCondition) {
const selected = filters.assetCondition
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
rows = rows.filter((row) => selected.includes(row.assetCondition));
}
if (filters.dispositionStatus) {
const selected = filters.dispositionStatus
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
rows = rows.filter((row) => selected.includes(row.dispositionStatus));
}
if (filters.sort) {
try {
const [primary] = JSON.parse(filters.sort) as Array<{ id: string; desc: boolean }>;
if (primary) {
rows = rows.toSorted((a, b) => {
const left = String((a as Record<string, unknown>)[primary.id] ?? '');
const right = String((b as Record<string, unknown>)[primary.id] ?? '');
return primary.desc ? right.localeCompare(left) : left.localeCompare(right);
});
}
} catch {
rows = rows.toSorted((a, b) => a.assetCode.localeCompare(b.assetCode));
}
} else {
rows = rows.toSorted((a, b) => a.assetCode.localeCompare(b.assetCode));
}
const page = filters.page ?? 1;
const limit = filters.limit ?? 10;
const offset = (page - 1) * limit;
return {
total: rows.length,
offset,
limit,
assets: rows.slice(offset, offset + limit).map(formatAsset)
};
}
export async function getAssetById(organizationId: string, assetId: string) {
const rows = await getBaseAssetRows(organizationId);
const asset = rows.find((row) => row.id === assetId);
if (!asset) {
throw new AuthError('Asset not found', 404);
}
return formatAsset(asset);
}
export async function getAssetRepairs(organizationId: string, assetId: string) {
const rows = await db.query.assetRepairs.findMany({
where: and(eq(assetRepairs.organizationId, organizationId), eq(assetRepairs.assetId, assetId)),
orderBy: desc(assetRepairs.repairDate)
});
return rows.map<AssetRepair>((row) => ({
id: row.id,
assetId: row.assetId,
repairDate: row.repairDate.toISOString(),
vendor: row.vendor,
problem: row.problem,
resolution: row.resolution,
cost: row.cost ? Number(row.cost) : null
}));
}
export async function getAssetMovements(organizationId: string, assetId?: string) {
const conditions = [eq(assetMovements.organizationId, organizationId)];
if (assetId) {
conditions.push(eq(assetMovements.assetId, assetId));
}
const rows = await db
.select({
id: assetMovements.id,
assetId: assetMovements.assetId,
assetUid: assets.assetUid,
assetCode: assets.assetCode,
assetName: assets.assetName,
eventType: assetMovements.eventType,
eventDate: assetMovements.eventDate,
reason: assetMovements.reason,
referenceDocument: assetMovements.referenceDocument,
performedByName: users.name,
metadata: assetMovements.metadata
})
.from(assetMovements)
.innerJoin(assets, eq(assets.id, assetMovements.assetId))
.leftJoin(users, eq(users.id, assetMovements.performedByUserId))
.where(and(...conditions))
.orderBy(desc(assetMovements.eventDate));
return rows.map<AssetMovement>((row) => ({
id: row.id,
assetId: row.assetId,
assetUid: row.assetUid,
assetCode: row.assetCode,
assetName: row.assetName,
eventType: row.eventType as AssetMovement['eventType'],
eventDate: row.eventDate.toISOString(),
reason: row.reason,
referenceDocument: row.referenceDocument,
performedByName: row.performedByName,
metadata: (row.metadata as Record<string, unknown> | null) ?? null
}));
}
export async function getAssetOptions(organizationId: string): Promise<AssetOptionsResponse> {
const [siteRows, departmentRows, locationRows, employeeRows] = await Promise.all([
db.select().from(sites).where(eq(sites.organizationId, organizationId)).orderBy(asc(sites.name)),
db
.select()
.from(departments)
.where(eq(departments.organizationId, organizationId))
.orderBy(asc(departments.name)),
listLocations(organizationId),
listEmployees(organizationId)
]);
return {
success: true,
time: new Date().toISOString(),
message: 'Asset options loaded successfully',
sites: siteRows.map((row) => ({
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name
})),
departments: departmentRows.map((row) => ({
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name
})),
locations: locationRows,
employees: employeeRows
};
}
async function listLocations(organizationId: string) {
const rows = await db
.select({
id: locations.id,
organizationId: locations.organizationId,
siteId: locations.siteId,
siteName: sites.name,
building: locations.building,
floor: locations.floor,
area: locations.area,
name: locations.name
})
.from(locations)
.leftJoin(sites, eq(sites.id, locations.siteId))
.where(eq(locations.organizationId, organizationId))
.orderBy(asc(locations.name));
return rows;
}
async function listEmployees(organizationId: string) {
const rows = await db
.select({
id: employees.id,
organizationId: employees.organizationId,
employeeNo: employees.employeeNo,
name: employees.name,
departmentId: employees.departmentId,
departmentName: departments.name,
siteId: employees.siteId,
siteName: sites.name,
status: employees.status
})
.from(employees)
.leftJoin(departments, eq(departments.id, employees.departmentId))
.leftJoin(sites, eq(sites.id, employees.siteId))
.where(eq(employees.organizationId, organizationId))
.orderBy(asc(employees.name));
return rows;
}
async function buildAssetUid() {
const result = await db.select({ count: sql<number>`count(*)::int` }).from(assets);
const nextValue = (result[0]?.count ?? 0) + 1;
return `AST-${String(nextValue).padStart(6, '0')}`;
}
function buildSnapshot(asset: Asset) {
return {
assetCode: asset.assetCode,
assetName: asset.assetName,
siteId: asset.siteId,
departmentId: asset.departmentId,
locationId: asset.locationId,
currentEmployeeId: asset.currentEmployeeId,
status: asset.status,
assetCondition: asset.assetCondition,
dispositionStatus: asset.dispositionStatus
};
}
async function insertMovement(tx: Pick<typeof db, 'insert'>, values: {
assetId: string;
organizationId: string;
eventType: string;
performedByUserId: string;
reason?: string | null;
referenceDocument?: string | null;
snapshotBefore?: Record<string, unknown> | null;
snapshotAfter?: Record<string, unknown> | null;
metadata?: Record<string, unknown> | null;
eventDate?: Date;
}) {
await tx.insert(assetMovements).values({
id: crypto.randomUUID(),
assetId: values.assetId,
organizationId: values.organizationId,
eventType: values.eventType,
eventDate: values.eventDate ?? new Date(),
reason: values.reason ?? null,
referenceDocument: values.referenceDocument ?? null,
performedByUserId: values.performedByUserId,
snapshotBefore: values.snapshotBefore ?? null,
snapshotAfter: values.snapshotAfter ?? null,
metadata: values.metadata ?? null
});
}
export async function createAsset(organizationId: string, performedByUserId: string, payload: AssetMutationPayload) {
const assetId = crypto.randomUUID();
const assetUid = await buildAssetUid();
await db.transaction(async (tx) => {
await tx.insert(assets).values({
id: assetId,
organizationId,
assetUid,
assetCode: payload.assetCode,
assetName: payload.assetName,
assetType: payload.assetType,
assetCategory: payload.assetCategory,
brand: sanitizeText(payload.brand),
model: sanitizeText(payload.model),
specification: sanitizeText(payload.specification),
serialNumber: sanitizeText(payload.serialNumber),
siteId: payload.siteId ?? null,
departmentId: payload.departmentId ?? null,
locationId: payload.locationId ?? null,
currentEmployeeId: payload.currentEmployeeId ?? null,
custodianTeam: sanitizeText(payload.custodianTeam),
purchaseDate: asDate(payload.purchaseDate),
warrantyExpiryDate: asDate(payload.warrantyExpiryDate),
eosDate: asDate(payload.eosDate),
eolDate: asDate(payload.eolDate),
eopDate: asDate(payload.eopDate),
status: payload.status,
assetCondition: payload.assetCondition,
dispositionStatus: payload.dispositionStatus,
notes: sanitizeText(payload.notes)
});
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'CREATE',
performedByUserId,
snapshotAfter: {
assetCode: payload.assetCode,
assetName: payload.assetName,
status: payload.status,
assetCondition: payload.assetCondition,
dispositionStatus: payload.dispositionStatus,
assetUid
}
});
});
return assetId;
}
export async function updateAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetMutationPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
await db.transaction(async (tx) => {
await tx
.update(assets)
.set({
assetCode: payload.assetCode,
assetName: payload.assetName,
assetType: payload.assetType,
assetCategory: payload.assetCategory,
brand: sanitizeText(payload.brand),
model: sanitizeText(payload.model),
specification: sanitizeText(payload.specification),
serialNumber: sanitizeText(payload.serialNumber),
siteId: payload.siteId ?? null,
departmentId: payload.departmentId ?? null,
locationId: payload.locationId ?? null,
currentEmployeeId: payload.currentEmployeeId ?? null,
custodianTeam: sanitizeText(payload.custodianTeam),
purchaseDate: asDate(payload.purchaseDate),
warrantyExpiryDate: asDate(payload.warrantyExpiryDate),
eosDate: asDate(payload.eosDate),
eolDate: asDate(payload.eolDate),
eopDate: asDate(payload.eopDate),
status: payload.status,
assetCondition: payload.assetCondition,
dispositionStatus: payload.dispositionStatus,
notes: sanitizeText(payload.notes),
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'UPDATE',
performedByUserId,
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset)
});
});
}
export async function assignAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetAssignmentPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
await db.transaction(async (tx) => {
await tx.insert(assetAssignments).values({
id: crypto.randomUUID(),
assetId,
organizationId,
employeeId: payload.employeeId,
departmentId: payload.departmentId ?? currentAsset.departmentId,
locationId: payload.locationId ?? currentAsset.locationId,
siteId: payload.siteId ?? currentAsset.siteId,
assignedAt: asDate(payload.assignDate) ?? new Date(),
documentAttachment: sanitizeText(payload.documentAttachment),
assignedByUserId: performedByUserId
});
await tx
.update(assets)
.set({
currentEmployeeId: payload.employeeId,
departmentId: payload.departmentId ?? currentAsset.departmentId,
locationId: payload.locationId ?? currentAsset.locationId,
siteId: payload.siteId ?? currentAsset.siteId,
status: 'ASSIGNED',
dispositionStatus: 'NONE',
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'ASSIGN',
performedByUserId,
reason: payload.reason ?? null,
referenceDocument: payload.documentAttachment,
eventDate: asDate(payload.assignDate) ?? new Date(),
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset),
metadata: {
employeeId: payload.employeeId,
departmentId: payload.departmentId ?? null,
locationId: payload.locationId ?? null,
siteId: payload.siteId ?? null,
documentAttachment: payload.documentAttachment
}
});
});
}
export async function transferAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetTransferPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
const transferEventDate = asDate(payload.transferDate) ?? new Date();
const nextAssetCode = sanitizeText(payload.newAssetCode) ?? currentAsset.assetCode;
const nextEmployeeId = payload.newEmployeeId ?? currentAsset.currentEmployeeId;
const nextDepartmentId = payload.newDepartmentId ?? currentAsset.departmentId;
const nextLocationId = payload.newLocationId ?? currentAsset.locationId;
const nextSiteId = payload.newSiteId ?? currentAsset.siteId;
await db.transaction(async (tx) => {
await tx
.update(assetAssignments)
.set({
returnedAt: transferEventDate,
returnedByUserId: performedByUserId,
updatedAt: new Date()
})
.where(
and(
eq(assetAssignments.assetId, assetId),
eq(assetAssignments.organizationId, organizationId),
isNull(assetAssignments.returnedAt)
)
);
if (nextEmployeeId) {
await tx.insert(assetAssignments).values({
id: crypto.randomUUID(),
assetId,
organizationId,
employeeId: nextEmployeeId,
departmentId: nextDepartmentId,
locationId: nextLocationId,
siteId: nextSiteId,
assignedAt: transferEventDate,
documentAttachment: sanitizeText(payload.referenceDocument),
assignedByUserId: performedByUserId
});
}
await tx
.update(assets)
.set({
currentEmployeeId: nextEmployeeId,
departmentId: nextDepartmentId,
locationId: nextLocationId,
siteId: nextSiteId,
assetCode: nextAssetCode,
status: nextEmployeeId ? 'ASSIGNED' : 'AVAILABLE',
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'TRANSFER',
performedByUserId,
reason: payload.reason ?? null,
referenceDocument: payload.referenceDocument,
eventDate: transferEventDate,
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset),
metadata: {
oldEmployeeId: currentAsset.currentEmployeeId,
newEmployeeId: nextEmployeeId,
oldDepartmentId: currentAsset.departmentId,
newDepartmentId: nextDepartmentId,
oldLocationId: currentAsset.locationId,
newLocationId: nextLocationId,
oldSiteId: currentAsset.siteId,
newSiteId: nextSiteId,
oldAssetCode: currentAsset.assetCode,
newAssetCode: nextAssetCode
}
});
});
}
export async function returnAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetReturnPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
const returnEventDate = asDate(payload.returnDate) ?? new Date();
const nextDispositionStatus = payload.assetCondition === 'DAMAGED' ? 'WAITING_REPAIR' : 'NONE';
await db.transaction(async (tx) => {
await tx
.update(assetAssignments)
.set({
returnedAt: returnEventDate,
returnedByUserId: performedByUserId,
updatedAt: new Date()
})
.where(
and(
eq(assetAssignments.assetId, assetId),
eq(assetAssignments.organizationId, organizationId),
isNull(assetAssignments.returnedAt)
)
);
await tx
.update(assets)
.set({
currentEmployeeId: null,
status: 'AVAILABLE',
assetCondition: payload.assetCondition,
dispositionStatus: nextDispositionStatus,
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'RETURN',
performedByUserId,
reason: payload.remark,
eventDate: returnEventDate,
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset),
metadata: {
assetCondition: payload.assetCondition,
dispositionStatus: nextDispositionStatus
}
});
});
}
export async function repairAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetRepairPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
await db.transaction(async (tx) => {
await tx.insert(assetRepairs).values({
id: crypto.randomUUID(),
assetId,
organizationId,
repairDate: asDate(payload.repairDate) ?? new Date(),
vendor: sanitizeText(payload.vendor),
problem: payload.problem,
resolution: sanitizeText(payload.resolution),
cost: payload.cost != null ? String(payload.cost) : null,
createdByUserId: performedByUserId
});
if (payload.markAsRepair ?? true) {
await tx
.update(assets)
.set({
status: 'IN_REPAIR',
dispositionStatus: 'WAITING_REPAIR',
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
}
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'REPAIR',
performedByUserId,
eventDate: asDate(payload.repairDate) ?? new Date(),
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset),
metadata: {
vendor: payload.vendor ?? null,
problem: payload.problem,
resolution: payload.resolution ?? null,
cost: payload.cost ?? null
}
});
});
}

View File

@@ -0,0 +1,65 @@
import { startOfMonth } from 'date-fns';
import { NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import {
getAssetMovements,
listAssetsForOrganization,
requireAssetAccess
} from '../../_lib';
export async function GET() {
try {
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const [result, movements] = await Promise.all([
listAssetsForOrganization(organization.id, {
page: 1,
limit: 10000
}),
getAssetMovements(organization.id)
]);
const monthStart = startOfMonth(new Date());
const summary = result.assets.reduce(
(acc, asset) => {
acc.cards.totalAssets += 1;
if (asset.status === 'ASSIGNED') acc.cards.assignedAssets += 1;
if (asset.status === 'AVAILABLE') acc.cards.inStockAssets += 1;
if (!asset.currentEmployeeId) acc.cards.withoutUser += 1;
if (!asset.locationId) acc.cards.withoutLocation += 1;
return acc;
},
{
cards: {
totalAssets: 0,
assignedAssets: 0,
inStockAssets: 0,
transferThisMonth: 0,
withoutUser: 0,
withoutLocation: 0
}
}
);
summary.cards.transferThisMonth = movements.filter((movement) => {
return (
movement.eventType === 'TRANSFER' && new Date(movement.eventDate).getTime() >= monthStart.getTime()
);
}).length;
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Asset dashboard summary loaded successfully',
...summary
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load dashboard summary' }, { status: 500 });
}
}

View File

@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { getAssetMovements, requireAssetAccess } from '../_lib';
export async function GET(_request: NextRequest) {
try {
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const movements = await getAssetMovements(organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Asset history loaded successfully',
movements
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load asset history' }, { status: 500 });
}
}

View File

@@ -0,0 +1,19 @@
import { NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { getAssetOptions, requireAssetAccess } from '../_lib';
export async function GET() {
try {
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const response = await getAssetOptions(organization.id);
return NextResponse.json(response);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load asset options' }, { status: 500 });
}
}

View File

@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import {
createAsset,
listAssetsForOrganization,
parseAssetPayload,
requireAssetAccess
} from './_lib';
import { AuthError } from '@/lib/auth/session';
import { PERMISSIONS } from '@/lib/auth/rbac';
export async function GET(request: NextRequest) {
try {
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const { searchParams } = request.nextUrl;
const result = await listAssetsForOrganization(organization.id, {
page: Number(searchParams.get('page') ?? 1),
limit: Number(searchParams.get('limit') ?? 10),
search: searchParams.get('search') ?? undefined,
status: searchParams.get('status') ?? undefined,
assetCondition: searchParams.get('assetCondition') ?? undefined,
dispositionStatus: searchParams.get('dispositionStatus') ?? undefined,
assetType: searchParams.get('assetType') ?? undefined,
sort: searchParams.get('sort') ?? undefined
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Assets loaded successfully',
total_assets: result.total,
offset: result.offset,
limit: result.limit,
assets: result.assets
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load assets' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetCreate);
const payload = parseAssetPayload(await request.json());
const id = await createAsset(organization.id, session.user.id, payload);
return NextResponse.json(
{
success: true,
message: 'Asset created successfully',
id
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create asset' }, { status: 500 });
}
}

View File

@@ -0,0 +1,3 @@
import { handlers } from '@/auth';
export const { GET, POST } = handlers;

View File

@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
export async function POST() {
return NextResponse.json(
{
message: 'Self-service sign-up is disabled. Please contact your administrator.'
},
{ status: 403 }
);
}

View File

@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
import { exampleProducts } from '@/features/example-dashboard/data';
function parseCategories(value: string | null) {
if (!value) return [];
return value
.split(/[.,]/)
.map((item) => item.trim())
.filter(Boolean);
}
function sortProducts(
items: typeof exampleProducts,
sort: string | null
) {
const defaultSorted = items.toSorted((a, b) => a.name.localeCompare(b.name));
if (!sort) {
return defaultSorted;
}
try {
const parsed = JSON.parse(sort) as Array<{ id: keyof (typeof exampleProducts)[number]; desc: boolean }>;
const [primarySort] = parsed;
if (!primarySort?.id) {
return defaultSorted;
}
const sorted = items.toSorted((left, right) => {
const a = left[primarySort.id];
const b = right[primarySort.id];
if (typeof a === 'number' && typeof b === 'number') {
return a - b;
}
return String(a).localeCompare(String(b));
});
return primarySort.desc ? sorted.toReversed() : sorted;
} catch {
return defaultSorted;
}
}
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const categories = parseCategories(searchParams.get('categories'));
const search = searchParams.get('search')?.trim().toLowerCase();
const sort = searchParams.get('sort');
const filtered = exampleProducts.filter((product) => {
const matchesCategory = categories.length ? categories.includes(product.category) : true;
const matchesSearch = search
? [product.name, product.description, product.category].some((value) =>
value.toLowerCase().includes(search)
)
: true;
return matchesCategory && matchesSearch;
});
const sorted = sortProducts(filtered, sort);
const offset = (page - 1) * limit;
const products = sorted.slice(offset, offset + limit);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Example products loaded successfully',
total_products: sorted.length,
offset,
limit,
products
});
}

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import { exampleUsers } from '@/features/example-dashboard/data';
function parseRoles(value: string | null) {
if (!value) return [];
return value
.split(/[.,]/)
.map((item) => item.trim())
.filter(Boolean);
}
function sortUsers(
items: typeof exampleUsers,
sort: string | null
) {
const defaultSorted = items.toSorted((a, b) => a.name.localeCompare(b.name));
if (!sort) {
return defaultSorted;
}
try {
const parsed = JSON.parse(sort) as Array<{ id: string; desc: boolean }>;
const [primarySort] = parsed;
if (!primarySort?.id) {
return defaultSorted;
}
const sorted = items.toSorted((left, right) => {
const a =
primarySort.id === 'role'
? `${left.systemRole}:${left.activeMembershipRole ?? ''}`
: primarySort.id === 'organizations'
? left.organizations.map((organization) => organization.name).join(', ')
: left.name;
const b =
primarySort.id === 'role'
? `${right.systemRole}:${right.activeMembershipRole ?? ''}`
: primarySort.id === 'organizations'
? right.organizations.map((organization) => organization.name).join(', ')
: right.name;
return a.localeCompare(b);
});
return primarySort.desc ? sorted.toReversed() : sorted;
} catch {
return defaultSorted;
}
}
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const roles = parseRoles(searchParams.get('roles'));
const search = searchParams.get('search')?.trim().toLowerCase();
const sort = searchParams.get('sort');
const filtered = exampleUsers.filter((user) => {
const matchesSearch = search
? [user.name, user.email].some((value) => value.toLowerCase().includes(search))
: true;
const matchesRole = roles.length
? roles.includes(user.systemRole) ||
user.memberships.some((membership) => roles.includes(membership.membershipRole))
: true;
return matchesSearch && matchesRole;
});
const sorted = sortUsers(filtered, sort);
const offset = (page - 1) * limit;
const users = sorted.slice(offset, offset + limit);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Example users loaded successfully',
total_users: sorted.length,
offset,
limit,
users
});
}

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthError } from '@/lib/auth/session';
import {
assertMasterDataEntity,
deleteMasterData,
parseMasterDataPayload,
requireMasterDataAccess,
updateMasterData
} from '../../_lib';
type Params = { params: Promise<{ entity: string; id: string }> };
export async function PUT(request: NextRequest, { params }: Params) {
try {
const { entity: rawEntity, id } = await params;
const entity = assertMasterDataEntity(rawEntity);
const { organization } = await requireMasterDataAccess();
const payload = parseMasterDataPayload(entity, await request.json());
await updateMasterData(entity, organization.id, id, payload);
return NextResponse.json({
success: true,
message: `${entity} updated successfully`
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update master data' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { entity: rawEntity, id } = await params;
const entity = assertMasterDataEntity(rawEntity);
const { organization } = await requireMasterDataAccess();
await deleteMasterData(entity, organization.id, id);
return NextResponse.json({
success: true,
message: `${entity} deleted successfully`
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to delete master data' }, { status: 500 });
}
}

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthError } from '@/lib/auth/session';
import {
assertMasterDataEntity,
createMasterData,
listMasterData,
parseMasterDataPayload,
requireMasterDataAccess
} from '../_lib';
type Params = { params: Promise<{ entity: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { entity: rawEntity } = await params;
const entity = assertMasterDataEntity(rawEntity);
const { organization } = await requireMasterDataAccess();
const items = await listMasterData(entity, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: `${entity} loaded successfully`,
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load master data' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { entity: rawEntity } = await params;
const entity = assertMasterDataEntity(rawEntity);
const { organization } = await requireMasterDataAccess();
const payload = parseMasterDataPayload(entity, await request.json());
const id = await createMasterData(entity, organization.id, payload);
return NextResponse.json(
{
success: true,
message: `${entity} created successfully`,
id
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create master data' }, { status: 500 });
}
}

View File

@@ -0,0 +1,275 @@
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import { departments, employees, locations, sites } from '@/db/schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
import type {
Department,
Employee,
Location,
MasterDataEntity,
MasterDataMutationPayload,
Site
} from '@/features/assets/api/types';
const masterDataEntities = ['sites', 'departments', 'locations', 'employees'] as const;
const entitySchemaMap = {
sites: z.object({
code: z.string().min(1),
name: z.string().min(1)
}),
departments: z.object({
code: z.string().min(1),
name: z.string().min(1)
}),
locations: z.object({
name: z.string().min(1),
siteId: z.string().optional().nullable(),
building: z.string().optional(),
floor: z.string().optional(),
area: z.string().optional()
}),
employees: z.object({
employeeNo: z.string().min(1),
name: z.string().min(1),
departmentId: z.string().optional().nullable(),
siteId: z.string().optional().nullable(),
status: z.string().default('active')
})
} as const;
export async function requireMasterDataAccess() {
return requireOrganizationAccess({ permission: PERMISSIONS.masterDataManage });
}
export function assertMasterDataEntity(entity: string): MasterDataEntity {
if ((masterDataEntities as readonly string[]).includes(entity)) {
return entity as MasterDataEntity;
}
throw new Error('Invalid master data entity');
}
export function parseMasterDataPayload(
entity: MasterDataEntity,
body: unknown
): MasterDataMutationPayload {
const schema = entitySchemaMap[entity];
const parsed = schema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid master data payload');
}
return parsed.data;
}
export async function listMasterData(entity: MasterDataEntity, organizationId: string) {
switch (entity) {
case 'sites': {
const rows = await db.query.sites.findMany({
where: eq(sites.organizationId, organizationId)
});
return rows.map<Site>((row) => ({
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name
}));
}
case 'departments': {
const rows = await db.query.departments.findMany({
where: eq(departments.organizationId, organizationId)
});
return rows.map<Department>((row) => ({
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name
}));
}
case 'locations': {
const rows = await db
.select({
id: locations.id,
organizationId: locations.organizationId,
siteId: locations.siteId,
siteName: sites.name,
building: locations.building,
floor: locations.floor,
area: locations.area,
name: locations.name
})
.from(locations)
.leftJoin(sites, eq(sites.id, locations.siteId))
.where(eq(locations.organizationId, organizationId));
return rows.map<Location>((row) => ({
id: row.id,
organizationId: row.organizationId,
siteId: row.siteId,
siteName: row.siteName,
building: row.building,
floor: row.floor,
area: row.area,
name: row.name
}));
}
case 'employees': {
const rows = await db
.select({
id: employees.id,
organizationId: employees.organizationId,
employeeNo: employees.employeeNo,
name: employees.name,
departmentId: employees.departmentId,
departmentName: departments.name,
siteId: employees.siteId,
siteName: sites.name,
status: employees.status
})
.from(employees)
.leftJoin(departments, eq(departments.id, employees.departmentId))
.leftJoin(sites, eq(sites.id, employees.siteId))
.where(eq(employees.organizationId, organizationId));
return rows.map<Employee>((row) => ({
id: row.id,
organizationId: row.organizationId,
employeeNo: row.employeeNo,
name: row.name,
departmentId: row.departmentId,
departmentName: row.departmentName,
siteId: row.siteId,
siteName: row.siteName,
status: row.status
}));
}
}
}
export async function createMasterData(
entity: MasterDataEntity,
organizationId: string,
payload: MasterDataMutationPayload
) {
const id = crypto.randomUUID();
switch (entity) {
case 'sites':
await db.insert(sites).values({
id,
organizationId,
code: payload.code!,
name: payload.name
});
break;
case 'departments':
await db.insert(departments).values({
id,
organizationId,
code: payload.code!,
name: payload.name
});
break;
case 'locations':
await db.insert(locations).values({
id,
organizationId,
siteId: payload.siteId ?? null,
building: payload.building ?? null,
floor: payload.floor ?? null,
area: payload.area ?? null,
name: payload.name
});
break;
case 'employees':
await db.insert(employees).values({
id,
organizationId,
employeeNo: payload.employeeNo!,
name: payload.name,
departmentId: payload.departmentId ?? null,
siteId: payload.siteId ?? null,
status: payload.status ?? 'active'
});
break;
}
return id;
}
export async function updateMasterData(
entity: MasterDataEntity,
organizationId: string,
id: string,
payload: MasterDataMutationPayload
) {
switch (entity) {
case 'sites':
await db
.update(sites)
.set({ code: payload.code!, name: payload.name, updatedAt: new Date() })
.where(and(eq(sites.id, id), eq(sites.organizationId, organizationId)));
break;
case 'departments':
await db
.update(departments)
.set({ code: payload.code!, name: payload.name, updatedAt: new Date() })
.where(and(eq(departments.id, id), eq(departments.organizationId, organizationId)));
break;
case 'locations':
await db
.update(locations)
.set({
siteId: payload.siteId ?? null,
building: payload.building ?? null,
floor: payload.floor ?? null,
area: payload.area ?? null,
name: payload.name,
updatedAt: new Date()
})
.where(and(eq(locations.id, id), eq(locations.organizationId, organizationId)));
break;
case 'employees':
await db
.update(employees)
.set({
employeeNo: payload.employeeNo!,
name: payload.name,
departmentId: payload.departmentId ?? null,
siteId: payload.siteId ?? null,
status: payload.status ?? 'active',
updatedAt: new Date()
})
.where(and(eq(employees.id, id), eq(employees.organizationId, organizationId)));
break;
}
}
export async function deleteMasterData(entity: MasterDataEntity, organizationId: string, id: string) {
switch (entity) {
case 'sites':
await db.delete(sites).where(and(eq(sites.id, id), eq(sites.organizationId, organizationId)));
break;
case 'departments':
await db
.delete(departments)
.where(and(eq(departments.id, id), eq(departments.organizationId, organizationId)));
break;
case 'locations':
await db
.delete(locations)
.where(and(eq(locations.id, id), eq(locations.organizationId, organizationId)));
break;
case 'employees':
await db
.delete(employees)
.where(and(eq(employees.id, id), eq(employees.organizationId, organizationId)));
break;
}
}

View File

@@ -0,0 +1,51 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { memberships, organizations, users } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError, requireSession } from '@/lib/auth/session';
export async function PATCH(request: NextRequest) {
try {
const session = await requireSession();
const body = (await request.json()) as { organizationId?: string };
const organizationId = body.organizationId;
if (!organizationId) {
return NextResponse.json({ message: 'organizationId is required' }, { status: 400 });
}
if (session.user.systemRole !== 'super_admin') {
const membership = await db.query.memberships.findFirst({
where: and(
eq(memberships.userId, session.user.id),
eq(memberships.organizationId, organizationId)
)
});
if (!membership) {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
} else {
const organization = await db.query.organizations.findFirst({
where: eq(organizations.id, organizationId)
});
if (!organization) {
return NextResponse.json({ message: 'Organization not found' }, { status: 404 });
}
}
await db
.update(users)
.set({ activeOrganizationId: organizationId })
.where(eq(users.id, session.user.id));
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to switch organization' }, { status: 500 });
}
}

View File

@@ -0,0 +1,132 @@
import { eq, inArray } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { memberships, organizations, users } from '@/db/schema';
import { getDefaultPermissions } from '@/lib/auth/rbac';
import { AuthError, requireSession, requireSystemRole } from '@/lib/auth/session';
import { db } from '@/lib/db';
function slugifyWorkspaceName(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export async function GET(request: NextRequest) {
try {
const session = await requireSession();
const scope = request.nextUrl.searchParams.get('scope');
if (session.user.systemRole === 'super_admin') {
const rows = await db.select().from(organizations);
return NextResponse.json({
organizations: rows.map((organization) => ({
id: organization.id,
name: organization.name,
slug: organization.slug,
plan: organization.plan,
imageUrl: organization.imageUrl,
role: 'admin',
businessRole: 'it_admin',
canManageUsers: true
}))
});
}
const membershipRows = await db.query.memberships.findMany({
where: eq(memberships.userId, session.user.id)
});
const filteredMemberships =
scope === 'manageable'
? membershipRows.filter((membership) => membership.role === 'admin')
: membershipRows;
if (!filteredMemberships.length) {
return NextResponse.json({ organizations: [] });
}
const organizationRows = await db
.select()
.from(organizations)
.where(inArray(organizations.id, filteredMemberships.map((membership) => membership.organizationId)));
const membershipMap = new Map(
filteredMemberships.map((membership) => [membership.organizationId, membership])
);
return NextResponse.json({
organizations: organizationRows.map((organization) => {
const membership = membershipMap.get(organization.id);
return {
id: organization.id,
name: organization.name,
slug: organization.slug,
plan: organization.plan,
imageUrl: organization.imageUrl,
role: membership?.role ?? 'user',
businessRole: membership?.businessRole ?? 'viewer',
canManageUsers: membership?.role === 'admin'
};
})
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load organizations' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const session = await requireSystemRole('super_admin');
const body = (await request.json()) as { name?: string };
const name = body.name?.trim();
if (!name) {
return NextResponse.json({ message: 'Workspace name is required' }, { status: 400 });
}
const organizationId = crypto.randomUUID();
const baseSlug = slugifyWorkspaceName(name);
const slug = `${baseSlug || 'workspace'}-${organizationId.slice(0, 6)}`;
await db.insert(organizations).values({
id: organizationId,
name,
slug,
plan: 'free',
createdBy: session.user.id
});
await db.insert(memberships).values({
id: crypto.randomUUID(),
userId: session.user.id,
organizationId,
role: 'admin',
businessRole: 'it_admin',
permissions: getDefaultPermissions('admin', 'it_admin')
});
await db
.update(users)
.set({ activeOrganizationId: organizationId })
.where(eq(users.id, session.user.id));
return NextResponse.json({
success: true,
organizationId
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to create organization' }, { status: 500 });
}
}

View File

@@ -0,0 +1,128 @@
import { and, eq, type InferSelectModel } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { products } from '@/db/schema';
import type { ProductMutationPayload } from '@/features/products/api/types';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
function toProductResponse(product: InferSelectModel<typeof products>) {
return {
id: product.id,
organization_id: product.organizationId,
photo_url: product.photoUrl,
name: product.name,
description: product.description,
created_at: product.createdAt.toISOString(),
price: product.price,
category: product.category,
updated_at: product.updatedAt.toISOString()
};
}
export async function GET(request: NextRequest, { params }: Params) {
try {
const { organization } = await requireOrganizationAccess();
const { id } = await params;
const product = await db.query.products.findFirst({
where: and(eq(products.id, Number(id)), eq(products.organizationId, organization.id))
});
if (!product) {
return NextResponse.json(
{ success: false, message: `Product with ID ${id} not found` },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: `Product with ID ${id} found`,
product: toProductResponse(product)
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load product' }, { status: 500 });
}
}
export async function PATCH(request: NextRequest, { params }: Params) {
try {
const { organization } = await requireOrganizationAccess();
const { id } = await params;
const body = (await request.json()) as ProductMutationPayload;
const existingProduct = await db.query.products.findFirst({
where: and(eq(products.id, Number(id)), eq(products.organizationId, organization.id))
});
if (!existingProduct) {
return NextResponse.json(
{ success: false, message: `Product with ID ${id} not found` },
{ status: 404 }
);
}
const [product] = await db
.update(products)
.set({
name: body.name,
category: body.category,
price: body.price,
description: body.description,
photoUrl: body.photoDataUrl || existingProduct.photoUrl,
updatedAt: new Date()
})
.where(and(eq(products.id, Number(id)), eq(products.organizationId, organization.id)))
.returning();
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Product updated successfully',
product: toProductResponse(product)
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to update product' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: Params) {
try {
const { organization } = await requireOrganizationAccess();
const { id } = await params;
const [deletedProduct] = await db
.delete(products)
.where(and(eq(products.id, Number(id)), eq(products.organizationId, organization.id)))
.returning({ id: products.id });
if (!deletedProduct) {
return NextResponse.json(
{ success: false, message: `Product with ID ${id} not found` },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
message: 'Product deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to delete product' }, { status: 500 });
}
}

View File

@@ -0,0 +1,146 @@
import { and, asc, count, desc, eq, ilike, inArray, or } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { products } from '@/db/schema';
import type { ProductMutationPayload } from '@/features/products/api/types';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
const sortMap = {
name: products.name,
category: products.category,
price: products.price,
created_at: products.createdAt,
updated_at: products.updatedAt
} as const;
function buildProductImageUrl(name: string) {
return `https://placehold.co/120x120/336b4a/ffffff/png?text=${encodeURIComponent(name.slice(0, 12))}`;
}
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess();
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const categories = searchParams.get('categories');
const search = searchParams.get('search');
const sort = searchParams.get('sort');
const categoryValues = categories
? categories
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean)
: [];
const filters = [
eq(products.organizationId, organization.id),
...(categoryValues.length ? [inArray(products.category, categoryValues)] : []),
...(search
? [
or(
ilike(products.name, `%${search}%`),
ilike(products.description, `%${search}%`),
ilike(products.category, `%${search}%`)
)!
]
: [])
];
const where = filters.length === 1 ? filters[0] : and(...filters);
const [totalResult] = await db.select({ value: count() }).from(products).where(where);
let orderByClause = desc(products.createdAt);
if (sort) {
try {
const sortItems = JSON.parse(sort) as Array<{ id: keyof typeof sortMap; desc: boolean }>;
const primarySort = sortItems[0];
if (primarySort?.id && sortMap[primarySort.id]) {
orderByClause = primarySort.desc
? desc(sortMap[primarySort.id])
: asc(sortMap[primarySort.id]);
}
} catch {
orderByClause = desc(products.createdAt);
}
}
const offset = (page - 1) * limit;
const rows = await db.select().from(products).where(where).orderBy(orderByClause).limit(limit).offset(offset);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Products loaded successfully',
total_products: totalResult?.value ?? 0,
offset,
limit,
products: rows.map((product) => ({
id: product.id,
organization_id: product.organizationId,
photo_url: product.photoUrl,
name: product.name,
description: product.description,
created_at: product.createdAt.toISOString(),
price: product.price,
category: product.category,
updated_at: product.updatedAt.toISOString()
}))
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load products' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess();
const body = (await request.json()) as ProductMutationPayload;
const [product] = await db
.insert(products)
.values({
organizationId: organization.id,
name: body.name,
category: body.category,
price: body.price,
description: body.description,
photoUrl: body.photoDataUrl || buildProductImageUrl(body.name)
})
.returning();
return NextResponse.json(
{
success: true,
time: new Date().toISOString(),
message: 'Product created successfully',
product: {
id: product.id,
organization_id: product.organizationId,
photo_url: product.photoUrl,
name: product.name,
description: product.description,
created_at: product.createdAt.toISOString(),
price: product.price,
category: product.category,
updated_at: product.updatedAt.toISOString()
}
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to create product' }, { status: 500 });
}
}

View File

@@ -0,0 +1,125 @@
import { hash } from 'bcryptjs';
import { eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { memberships, users } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import {
assertManageableTargetUser,
buildMembershipValues,
parseUserMutationPayload,
requireUserManagementAccess,
validateManagedOrganizations
} from '../_lib';
type Params = { params: Promise<{ id: string }> };
export async function PUT(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { session, isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
const payload = parseUserMutationPayload(await request.json());
const targetUser = await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
await validateManagedOrganizations(payload.memberships, manageableOrganizationIds, isSuperAdmin);
if (!isSuperAdmin && payload.systemRole !== 'user') {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
if (!isSuperAdmin && targetUser.systemRole === 'super_admin') {
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
}
const userWithSameEmail = await db.query.users.findFirst({
where: eq(users.email, payload.email)
});
if (userWithSameEmail && userWithSameEmail.id !== id) {
return NextResponse.json(
{ message: 'An account with this email already exists' },
{ status: 409 }
);
}
const updateValues: {
name: string;
email: string;
systemRole: 'super_admin' | 'user';
activeOrganizationId: string | null;
passwordHash?: string;
} = {
name: payload.name,
email: payload.email,
systemRole: targetUser.systemRole === 'super_admin' ? 'super_admin' : 'user',
activeOrganizationId: payload.memberships[0]?.organizationId ?? null
};
if (payload.password) {
if (payload.password.length < 8) {
return NextResponse.json({ message: 'Password must be at least 8 characters' }, { status: 400 });
}
updateValues.passwordHash = await hash(payload.password, 10);
}
await db.update(users).set(updateValues).where(eq(users.id, id));
await db.delete(memberships).where(eq(memberships.userId, id));
await db.insert(memberships).values(buildMembershipValues(id, payload.memberships));
if (
session.user.id === id &&
!payload.memberships.some(
(membership) => membership.organizationId === (session.user.activeOrganizationId ?? '')
)
) {
await db
.update(users)
.set({ activeOrganizationId: payload.memberships[0]?.organizationId ?? null })
.where(eq(users.id, id));
}
return NextResponse.json({
success: true,
message: 'User updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update user' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { session, isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
if (session.user.id === id) {
return NextResponse.json({ message: 'You cannot delete your own account' }, { status: 400 });
}
await assertManageableTargetUser(id, manageableOrganizationIds, isSuperAdmin);
await db.delete(memberships).where(eq(memberships.userId, id));
await db.delete(users).where(eq(users.id, id));
return NextResponse.json({
success: true,
message: 'User deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to delete user' }, { status: 500 });
}
}

194
src/app/api/users/_lib.ts Normal file
View File

@@ -0,0 +1,194 @@
import { eq, inArray } from 'drizzle-orm';
import { memberships, organizations, users } from '@/db/schema';
import {
getDefaultBusinessRole,
getDefaultPermissions,
isBusinessRole,
isMembershipRole,
isSystemRole
} from '@/lib/auth/rbac';
import { AuthError, requireSession } from '@/lib/auth/session';
import { db } from '@/lib/db';
export type UserMembershipInput = {
organizationId: string;
membershipRole: 'admin' | 'user';
businessRole: 'it_admin' | 'helpdesk' | 'infrastructure' | 'application' | 'auditor' | 'viewer';
};
export type UserMutationInput = {
name: string;
email: string;
password?: string;
systemRole: 'super_admin' | 'user';
memberships: UserMembershipInput[];
};
export async function requireUserManagementAccess() {
const session = await requireSession();
if (session.user.systemRole === 'super_admin') {
const allOrganizations = await db.select({ id: organizations.id }).from(organizations);
return {
session,
isSuperAdmin: true,
manageableOrganizationIds: allOrganizations.map((organization) => organization.id)
};
}
const adminMemberships = await db.query.memberships.findMany({
where: eq(memberships.userId, session.user.id)
});
const manageableOrganizationIds = adminMemberships
.filter((membership) => membership.role === 'admin')
.map((membership) => membership.organizationId);
if (!manageableOrganizationIds.length) {
throw new AuthError('Forbidden', 403);
}
return {
session,
isSuperAdmin: false,
manageableOrganizationIds
};
}
export function parseUserMutationPayload(body: unknown): UserMutationInput {
if (!body || typeof body !== 'object') {
throw new Error('Invalid user payload');
}
const payload = body as Record<string, unknown>;
const membershipsPayload = Array.isArray(payload.memberships) ? payload.memberships : [];
const name = typeof payload.name === 'string' ? payload.name.trim() : '';
const email = typeof payload.email === 'string' ? payload.email.trim().toLowerCase() : '';
const password = typeof payload.password === 'string' ? payload.password.trim() : undefined;
const systemRoleValue = typeof payload.systemRole === 'string' ? payload.systemRole : 'user';
if (!name || !email || !isSystemRole(systemRoleValue)) {
throw new Error('Invalid user payload');
}
const memberships = membershipsPayload
.map((membership) => {
if (!membership || typeof membership !== 'object') {
return null;
}
const value = membership as Record<string, unknown>;
const organizationId =
typeof value.organizationId === 'string' ? value.organizationId.trim() : '';
const membershipRole =
typeof value.membershipRole === 'string' ? value.membershipRole : '';
const businessRole =
typeof value.businessRole === 'string'
? value.businessRole
: getDefaultBusinessRole(
membershipRole === 'admin' || membershipRole === 'user' ? membershipRole : 'user'
);
if (!organizationId || !isMembershipRole(membershipRole) || !isBusinessRole(businessRole)) {
return null;
}
return {
organizationId,
membershipRole,
businessRole
};
})
.filter((membership): membership is UserMembershipInput => membership !== null);
const uniqueMemberships = [...new Map(memberships.map((membership) => [membership.organizationId, membership])).values()];
if (!uniqueMemberships.length) {
throw new Error('Select at least one organization');
}
return {
name,
email,
password,
systemRole: systemRoleValue,
memberships: uniqueMemberships
};
}
export async function validateManagedOrganizations(
targetMemberships: UserMembershipInput[],
manageableOrganizationIds: string[],
isSuperAdmin: boolean
) {
const organizationIds = targetMemberships.map((membership) => membership.organizationId);
const organizationRows = await db
.select({
id: organizations.id
})
.from(organizations)
.where(inArray(organizations.id, organizationIds));
if (organizationRows.length !== organizationIds.length) {
throw new Error('One or more organizations do not exist');
}
if (
!isSuperAdmin &&
organizationIds.some((organizationId) => !manageableOrganizationIds.includes(organizationId))
) {
throw new AuthError('Forbidden', 403);
}
}
export async function getUserMembershipRows(userId: string) {
return db.query.memberships.findMany({
where: eq(memberships.userId, userId)
});
}
export async function assertManageableTargetUser(
userId: string,
manageableOrganizationIds: string[],
isSuperAdmin: boolean
) {
const user = await db.query.users.findFirst({
where: eq(users.id, userId)
});
if (!user) {
throw new AuthError('User not found', 404);
}
if (user.systemRole === 'super_admin' && !isSuperAdmin) {
throw new AuthError('Only super admins can edit super admin users', 403);
}
if (!isSuperAdmin) {
const targetMemberships = await getUserMembershipRows(userId);
if (
!targetMemberships.length ||
targetMemberships.some(
(membership) => !manageableOrganizationIds.includes(membership.organizationId)
)
) {
throw new AuthError('Forbidden', 403);
}
}
return user;
}
export function buildMembershipValues(userId: string, targetMemberships: UserMembershipInput[]) {
return targetMemberships.map((membership) => ({
id: crypto.randomUUID(),
userId,
organizationId: membership.organizationId,
role: membership.membershipRole,
businessRole: membership.businessRole,
permissions: getDefaultPermissions(membership.membershipRole, membership.businessRole)
}));
}

292
src/app/api/users/route.ts Normal file
View File

@@ -0,0 +1,292 @@
import { hash } from 'bcryptjs';
import { asc, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { memberships, organizations, users } from '@/db/schema';
import { db } from '@/lib/db';
import { AuthError } from '@/lib/auth/session';
import {
buildMembershipValues,
parseUserMutationPayload,
requireUserManagementAccess,
validateManagedOrganizations
} from './_lib';
type UserRow = {
userId: string;
name: string;
email: string;
systemRole: string;
activeOrganizationId: string | null;
organizationId: string;
organizationName: string;
membershipRole: string;
businessRole: string;
};
function formatUsers(rows: UserRow[]) {
const usersMap = new Map<
string,
{
id: string;
name: string;
email: string;
systemRole: 'super_admin' | 'user';
activeMembershipRole: 'admin' | 'user' | null;
activeOrganizationId: string | null;
organizations: Array<{ id: string; name: string; role: 'admin' | 'user' }>;
memberships: Array<{
organizationId: string;
organizationName: string;
membershipRole: 'admin' | 'user';
businessRole:
| 'it_admin'
| 'helpdesk'
| 'infrastructure'
| 'application'
| 'auditor'
| 'viewer';
}>;
}
>();
for (const row of rows) {
const existing = usersMap.get(row.userId);
const organization = {
id: row.organizationId,
name: row.organizationName,
role: row.membershipRole === 'admin' ? 'admin' : 'user'
} as const;
const membership = {
organizationId: row.organizationId,
organizationName: row.organizationName,
membershipRole: organization.role,
businessRole:
row.businessRole === 'it_admin' ||
row.businessRole === 'helpdesk' ||
row.businessRole === 'infrastructure' ||
row.businessRole === 'application' ||
row.businessRole === 'auditor'
? row.businessRole
: 'viewer'
} as const;
if (existing) {
if (!existing.organizations.some((item) => item.id === organization.id)) {
existing.organizations.push(organization);
}
if (!existing.memberships.some((item) => item.organizationId === membership.organizationId)) {
existing.memberships.push(membership);
}
if (row.organizationId === existing.activeOrganizationId) {
existing.activeMembershipRole = organization.role;
}
continue;
}
usersMap.set(row.userId, {
id: row.userId,
name: row.name,
email: row.email,
systemRole: row.systemRole === 'super_admin' ? 'super_admin' : 'user',
activeMembershipRole:
row.organizationId === row.activeOrganizationId ? organization.role : null,
activeOrganizationId: row.activeOrganizationId,
organizations: [organization],
memberships: [membership]
});
}
return [...usersMap.values()].map((user) => ({
...user,
organizations: user.organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
memberships: user.memberships.toSorted((a, b) =>
a.organizationName.localeCompare(b.organizationName)
)
}));
}
function applyRoleFilter(usersList: ReturnType<typeof formatUsers>, roles?: string) {
if (!roles) {
return usersList;
}
const selectedRoles = roles
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
return usersList.filter(
(user) =>
selectedRoles.includes(user.systemRole) ||
user.memberships.some((membership) => selectedRoles.includes(membership.membershipRole))
);
}
function sortUsers(usersList: ReturnType<typeof formatUsers>, sort?: string) {
const defaultSort = usersList.toSorted((a, b) => a.name.localeCompare(b.name));
if (!sort) {
return defaultSort;
}
try {
const sortItems = JSON.parse(sort) as Array<{ id: string; desc: boolean }>;
const [primarySort] = sortItems;
if (!primarySort) {
return defaultSort;
}
const sorted = usersList.toSorted((a, b) => {
const left =
primarySort.id === 'role'
? `${a.systemRole}:${a.activeMembershipRole ?? ''}`
: primarySort.id === 'organizations'
? a.memberships.map((membership) => membership.organizationName).join(', ')
: a[primarySort.id as 'name' | 'email'] ?? a.name;
const right =
primarySort.id === 'role'
? `${b.systemRole}:${b.activeMembershipRole ?? ''}`
: primarySort.id === 'organizations'
? b.memberships.map((membership) => membership.organizationName).join(', ')
: b[primarySort.id as 'name' | 'email'] ?? b.name;
return String(left).localeCompare(String(right));
});
return primarySort.desc ? sorted.toReversed() : sorted;
} catch {
return defaultSort;
}
}
export async function GET(request: NextRequest) {
try {
const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const roles = searchParams.get('roles') ?? undefined;
const search = searchParams.get('search') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const rows = await db
.select({
userId: users.id,
name: users.name,
email: users.email,
systemRole: users.systemRole,
activeOrganizationId: users.activeOrganizationId,
organizationId: memberships.organizationId,
organizationName: organizations.name,
membershipRole: memberships.role,
businessRole: memberships.businessRole
})
.from(users)
.innerJoin(memberships, eq(memberships.userId, users.id))
.innerJoin(organizations, eq(organizations.id, memberships.organizationId))
.orderBy(asc(users.name));
const scopedRows = isSuperAdmin
? rows
: rows.filter((row) => manageableOrganizationIds.includes(row.organizationId));
const searchTerm = search?.trim().toLowerCase();
const searchedRows = searchTerm
? scopedRows.filter(
(row) =>
row.name.toLowerCase().includes(searchTerm) ||
row.email.toLowerCase().includes(searchTerm)
)
: scopedRows;
const formattedUsers = formatUsers(searchedRows);
const filteredUsers = applyRoleFilter(formattedUsers, roles);
const sortedUsers = sortUsers(filteredUsers, sort);
const offset = (page - 1) * limit;
const pagedUsers = sortedUsers.slice(offset, offset + limit);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Users loaded successfully',
total_users: sortedUsers.length,
offset,
limit,
users: pagedUsers
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load users' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { isSuperAdmin, manageableOrganizationIds } = await requireUserManagementAccess();
const payload = parseUserMutationPayload(await request.json());
if (!payload.password || payload.password.length < 8) {
return NextResponse.json(
{ message: 'Password must be at least 8 characters' },
{ status: 400 }
);
}
if (payload.systemRole === 'super_admin') {
return NextResponse.json(
{ message: 'Creating new super admin users is not supported in this form' },
{ status: 400 }
);
}
await validateManagedOrganizations(payload.memberships, manageableOrganizationIds, isSuperAdmin);
const existingUser = await db.query.users.findFirst({
where: eq(users.email, payload.email)
});
if (existingUser) {
return NextResponse.json(
{ message: 'An account with this email already exists' },
{ status: 409 }
);
}
const userId = crypto.randomUUID();
const passwordHash = await hash(payload.password, 10);
await db.insert(users).values({
id: userId,
name: payload.name,
email: payload.email,
passwordHash,
systemRole: payload.systemRole,
activeOrganizationId: payload.memberships[0]?.organizationId ?? null
});
await db.insert(memberships).values(buildMembershipValues(userId, payload.memberships));
return NextResponse.json(
{
success: true,
message: 'User created successfully'
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create user' }, { status: 500 });
}
}