Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
16
src/app/api/v1/files/[id]/download/route.ts
Normal file
16
src/app/api/v1/files/[id]/download/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getDownloadUrl } from '@/lib/services/files';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('files', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const result = await getDownloadUrl(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
16
src/app/api/v1/files/[id]/preview/route.ts
Normal file
16
src/app/api/v1/files/[id]/preview/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getPreviewUrl } from '@/lib/services/files';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('files', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const result = await getPreviewUrl(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: result });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
51
src/app/api/v1/files/[id]/route.ts
Normal file
51
src/app/api/v1/files/[id]/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { getFileById, updateFile, deleteFile } from '@/lib/services/files';
|
||||
import { updateFileSchema } from '@/lib/validators/files';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('files', 'view', async (req, ctx, params) => {
|
||||
try {
|
||||
const file = await getFileById(params.id!, ctx.portId);
|
||||
return NextResponse.json({ data: file });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('files', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const body = await parseBody(req, updateFileSchema);
|
||||
const updated = await updateFile(params.id!, ctx.portId, body, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return NextResponse.json({ data: updated });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('files', 'delete', async (req, ctx, params) => {
|
||||
try {
|
||||
await deleteFile(params.id!, ctx.portId, {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
75
src/app/api/v1/files/folders/[...path]/route.ts
Normal file
75
src/app/api/v1/files/folders/[...path]/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { minioClient } from '@/lib/minio';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
const renameFolderSchema = z.object({
|
||||
newPath: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
function sanitizeFolderPath(raw: string): string {
|
||||
return raw
|
||||
.replace(/\x00/g, '')
|
||||
.replace(/\.\.\//g, '')
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
export const PATCH = withAuth(
|
||||
withPermission('files', 'edit', async (req, ctx, params) => {
|
||||
try {
|
||||
const pathSegments = params.path;
|
||||
const currentPath = Array.isArray(pathSegments)
|
||||
? (pathSegments as string[]).join('/')
|
||||
: String(pathSegments);
|
||||
|
||||
const body = await parseBody(req, renameFolderSchema);
|
||||
const safeCurrent = sanitizeFolderPath(currentPath);
|
||||
const safeNew = sanitizeFolderPath(body.newPath);
|
||||
|
||||
if (!safeCurrent || !safeNew) {
|
||||
throw new ValidationError('Invalid folder path');
|
||||
}
|
||||
|
||||
const oldKey = `${ctx.portSlug}/${safeCurrent}${safeCurrent.endsWith('/') ? '' : '/'}`;
|
||||
const newKey = `${ctx.portSlug}/${safeNew}${safeNew.endsWith('/') ? '' : '/'}`;
|
||||
|
||||
// Create new marker, remove old
|
||||
await minioClient.putObject(env.MINIO_BUCKET, newKey, Buffer.alloc(0), 0, {
|
||||
'Content-Type': 'application/x-directory',
|
||||
});
|
||||
await minioClient.removeObject(env.MINIO_BUCKET, oldKey);
|
||||
|
||||
return NextResponse.json({ data: { path: newKey } });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const DELETE = withAuth(
|
||||
withPermission('files', 'delete', async (req, ctx, params) => {
|
||||
try {
|
||||
const pathSegments = params.path;
|
||||
const currentPath = Array.isArray(pathSegments)
|
||||
? (pathSegments as string[]).join('/')
|
||||
: String(pathSegments);
|
||||
|
||||
const safePath = sanitizeFolderPath(currentPath);
|
||||
if (!safePath) {
|
||||
throw new ValidationError('Invalid folder path');
|
||||
}
|
||||
|
||||
const folderKey = `${ctx.portSlug}/${safePath}${safePath.endsWith('/') ? '' : '/'}`;
|
||||
await minioClient.removeObject(env.MINIO_BUCKET, folderKey);
|
||||
|
||||
return new NextResponse(null, { status: 204 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
42
src/app/api/v1/files/folders/route.ts
Normal file
42
src/app/api/v1/files/folders/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { minioClient } from '@/lib/minio';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
const createFolderSchema = z.object({
|
||||
path: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('files', 'create', async (req, ctx) => {
|
||||
try {
|
||||
const body = await parseBody(req, createFolderSchema);
|
||||
|
||||
// Sanitize path — no null bytes, no path traversal
|
||||
const safePath = body.path
|
||||
.replace(/\x00/g, '')
|
||||
.replace(/\.\.\//g, '')
|
||||
.replace(/^\/+/, '')
|
||||
.replace(/\/+/g, '/');
|
||||
|
||||
if (!safePath) {
|
||||
throw new ValidationError('Invalid folder path');
|
||||
}
|
||||
|
||||
const folderKey = `${ctx.portSlug}/${safePath}${safePath.endsWith('/') ? '' : '/'}`;
|
||||
|
||||
// Create zero-byte marker object in MinIO
|
||||
await minioClient.putObject(env.MINIO_BUCKET, folderKey, Buffer.alloc(0), 0, {
|
||||
'Content-Type': 'application/x-directory',
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: { path: folderKey } }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
33
src/app/api/v1/files/route.ts
Normal file
33
src/app/api/v1/files/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseQuery } from '@/lib/api/route-helpers';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { listFiles } from '@/lib/services/files';
|
||||
import { listFilesSchema } from '@/lib/validators/files';
|
||||
|
||||
export const GET = withAuth(
|
||||
withPermission('files', 'view', async (req, ctx) => {
|
||||
try {
|
||||
const query = parseQuery(req, listFilesSchema);
|
||||
const result = await listFiles(ctx.portId, query);
|
||||
|
||||
const { page, limit } = query;
|
||||
const totalPages = Math.ceil(result.total / limit);
|
||||
|
||||
return NextResponse.json({
|
||||
data: result.data,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize: limit,
|
||||
total: result.total,
|
||||
totalPages,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
51
src/app/api/v1/files/upload/route.ts
Normal file
51
src/app/api/v1/files/upload/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { uploadFile } from '@/lib/services/files';
|
||||
import { uploadFileSchema } from '@/lib/validators/files';
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('files', 'create', async (req, ctx) => {
|
||||
try {
|
||||
const formData = await req.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
|
||||
if (!file) {
|
||||
throw new ValidationError('No file provided');
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
const metadata = uploadFileSchema.parse({
|
||||
filename: (formData.get('filename') as string | null) ?? file.name,
|
||||
clientId: formData.get('clientId') as string | undefined,
|
||||
category: formData.get('category') as string | undefined,
|
||||
entityType: formData.get('entityType') as string | undefined,
|
||||
entityId: formData.get('entityId') as string | undefined,
|
||||
});
|
||||
|
||||
const result = await uploadFile(
|
||||
ctx.portId,
|
||||
ctx.portSlug,
|
||||
{
|
||||
buffer,
|
||||
originalName: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
},
|
||||
metadata,
|
||||
{
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
);
|
||||
|
||||
return NextResponse.json({ data: result }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user