Initial commit
This commit is contained in:
25
apps/api/.env.example
Normal file
25
apps/api/.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# Application
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
HOST=0.0.0.0
|
||||
|
||||
# Database (see packages/db/.env.example)
|
||||
DATABASE_URL="postgresql://dwellops:dwellops@localhost:5432/dwellops_dev?schema=public"
|
||||
|
||||
# Auth
|
||||
BETTER_AUTH_SECRET="change-me-in-production-use-openssl-rand-base64-32"
|
||||
BETTER_AUTH_URL="http://localhost:3001"
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN="http://localhost:3000"
|
||||
|
||||
# Email / Mailpit (local dev)
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_FROM="noreply@dwellops.local"
|
||||
|
||||
# OIDC (optional — set OIDC_ENABLED=true to activate)
|
||||
OIDC_ENABLED=false
|
||||
# OIDC_ISSUER=https://your-idp.example.com
|
||||
# OIDC_CLIENT_ID=your-client-id
|
||||
# OIDC_CLIENT_SECRET=your-client-secret
|
||||
3
apps/api/eslint.config.js
Normal file
3
apps/api/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { base } from '@dwellops/config/eslint';
|
||||
|
||||
export default base;
|
||||
45
apps/api/package.json
Normal file
45
apps/api/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@dwellops/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc --noEmit",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"test": "vitest run --coverage",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify": "catalog:",
|
||||
"@fastify/cors": "catalog:",
|
||||
"@fastify/helmet": "catalog:",
|
||||
"@fastify/swagger": "catalog:",
|
||||
"@fastify/swagger-ui": "catalog:",
|
||||
"@fastify/env": "catalog:",
|
||||
"@fastify/rate-limit": "catalog:",
|
||||
"zod": "catalog:",
|
||||
"pino": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"fastify-plugin": "catalog:",
|
||||
"@dwellops/db": "workspace:*",
|
||||
"@dwellops/types": "workspace:*",
|
||||
"@dwellops/schemas": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
"tsx": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"pino-pretty": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
"@dwellops/config": "workspace:*",
|
||||
"@dwellops/test-utils": "workspace:*"
|
||||
}
|
||||
}
|
||||
89
apps/api/src/app.ts
Normal file
89
apps/api/src/app.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import Fastify, { type FastifyError } from 'fastify';
|
||||
import helmet from '@fastify/helmet';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import { corsPlugin } from './plugins/cors';
|
||||
import { swaggerPlugin } from './plugins/swagger';
|
||||
import { authPlugin } from './plugins/auth';
|
||||
import { healthRoutes } from './modules/health/health.routes';
|
||||
import { authRoutes } from './modules/auth/auth.routes';
|
||||
import { hoaRoutes } from './modules/hoa/hoa.routes';
|
||||
import { AppError } from './lib/errors';
|
||||
import { env } from './lib/env';
|
||||
|
||||
/**
|
||||
* Creates and configures the Fastify application.
|
||||
* Separated from index.ts to support testing without binding to a port.
|
||||
*/
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
...(env.NODE_ENV !== 'production' && {
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'SYS:standard',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
trustProxy: true,
|
||||
});
|
||||
|
||||
// Security headers
|
||||
await app.register(helmet, {
|
||||
contentSecurityPolicy: false, // Managed at edge/CDN in production
|
||||
});
|
||||
|
||||
// Rate limiting
|
||||
await app.register(rateLimit, {
|
||||
max: 100,
|
||||
timeWindow: '1 minute',
|
||||
});
|
||||
|
||||
// CORS
|
||||
await app.register(corsPlugin);
|
||||
|
||||
// API docs (must be before route registration)
|
||||
await app.register(swaggerPlugin);
|
||||
|
||||
// Session resolution (populates request.session)
|
||||
await app.register(authPlugin);
|
||||
|
||||
// Routes
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes);
|
||||
await app.register(hoaRoutes);
|
||||
|
||||
// Global error handler — converts AppError to structured JSON response.
|
||||
app.setErrorHandler((error: FastifyError, _request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
statusCode: error.statusCode,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Fastify validation error
|
||||
if ('validation' in error && error.validation) {
|
||||
return reply.status(400).send({
|
||||
statusCode: 400,
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Request validation failed',
|
||||
details: error.validation,
|
||||
});
|
||||
}
|
||||
|
||||
app.log.error({ err: error }, 'Unhandled error');
|
||||
return reply.status(500).send({
|
||||
statusCode: 500,
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'An unexpected error occurred',
|
||||
});
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
26
apps/api/src/index.ts
Normal file
26
apps/api/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { buildApp } from './app';
|
||||
import { env } from './lib/env';
|
||||
import { logger } from './lib/logger';
|
||||
|
||||
async function start(): Promise<void> {
|
||||
const app = await buildApp();
|
||||
|
||||
try {
|
||||
await app.listen({ port: env.PORT, host: env.HOST });
|
||||
logger.info({ port: env.PORT }, 'DwellOps API is running');
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to start server');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM received — shutting down gracefully');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
start().catch((err) => {
|
||||
logger.error({ err }, 'Startup error');
|
||||
process.exit(1);
|
||||
});
|
||||
57
apps/api/src/lib/auth.ts
Normal file
57
apps/api/src/lib/auth.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { betterAuth } from 'better-auth';
|
||||
import { prismaAdapter } from 'better-auth/adapters/prisma';
|
||||
import { magicLink, oidcProvider } from 'better-auth/plugins';
|
||||
import { prisma } from '@dwellops/db';
|
||||
import { env } from './env';
|
||||
import { logger } from './logger';
|
||||
|
||||
/**
|
||||
* Plugins array, conditionally extended with OIDC if enabled.
|
||||
*
|
||||
* NOTE: Passkey/WebAuthn support is not yet available in better-auth 1.5.x.
|
||||
* Track https://github.com/better-auth/better-auth for availability.
|
||||
* When released, import from 'better-auth/plugins' and add to this array.
|
||||
*/
|
||||
const plugins: Parameters<typeof betterAuth>[0]['plugins'] = [
|
||||
magicLink({
|
||||
sendMagicLink: async ({ email, url }) => {
|
||||
// In production, wire this to your transactional email provider (e.g. nodemailer via SMTP).
|
||||
// In development, Mailpit captures the email at http://localhost:8025.
|
||||
logger.info({ email, url }, 'Magic link requested');
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
if (env.OIDC_ENABLED) {
|
||||
if (!env.OIDC_ISSUER || !env.OIDC_CLIENT_ID || !env.OIDC_CLIENT_SECRET) {
|
||||
throw new Error(
|
||||
'OIDC_ENABLED=true but OIDC_ISSUER, OIDC_CLIENT_ID, or OIDC_CLIENT_SECRET is missing.',
|
||||
);
|
||||
}
|
||||
|
||||
plugins.push(
|
||||
oidcProvider({
|
||||
loginPage: '/auth/login',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configured Better Auth instance.
|
||||
*
|
||||
* Supported flows:
|
||||
* - Magic link (always enabled)
|
||||
* - OIDC (optional, controlled by OIDC_ENABLED env var)
|
||||
*
|
||||
* Passkey/WebAuthn: planned, pending better-auth plugin availability.
|
||||
* Email/password login is intentionally disabled.
|
||||
*/
|
||||
export const auth = betterAuth({
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
basePath: '/api/auth',
|
||||
database: prismaAdapter(prisma, { provider: 'postgresql' }),
|
||||
emailAndPassword: { enabled: false },
|
||||
plugins,
|
||||
trustedOrigins: [env.CORS_ORIGIN],
|
||||
});
|
||||
46
apps/api/src/lib/env.ts
Normal file
46
apps/api/src/lib/env.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(3001),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
BETTER_AUTH_SECRET: z.string().min(32),
|
||||
BETTER_AUTH_URL: z.string().url(),
|
||||
|
||||
CORS_ORIGIN: z.string().default('http://localhost:3000'),
|
||||
|
||||
SMTP_HOST: z.string().default('localhost'),
|
||||
SMTP_PORT: z.coerce.number().int().default(1025),
|
||||
SMTP_FROM: z.string().email().default('noreply@dwellops.local'),
|
||||
|
||||
OIDC_ENABLED: z
|
||||
.string()
|
||||
.default('false')
|
||||
.transform((v) => v === 'true'),
|
||||
OIDC_ISSUER: z.string().url().optional(),
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
/**
|
||||
* Validates and returns the current environment variables.
|
||||
* Throws a descriptive error on startup if required variables are missing.
|
||||
*/
|
||||
export function validateEnv(): Env {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
`Invalid environment variables:\n${result.error.issues
|
||||
.map((i) => ` ${i.path.join('.')}: ${i.message}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export const env = validateEnv();
|
||||
54
apps/api/src/lib/errors.ts
Normal file
54
apps/api/src/lib/errors.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Typed application error hierarchy.
|
||||
* Use these instead of raw Error objects so callers can identify error kinds.
|
||||
*/
|
||||
|
||||
export class AppError extends Error {
|
||||
/** HTTP status code to return. */
|
||||
readonly statusCode: number;
|
||||
/** Machine-readable error code. */
|
||||
readonly code: string;
|
||||
|
||||
constructor(message: string, statusCode = 500, code = 'INTERNAL_ERROR') {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.statusCode = statusCode;
|
||||
this.code = code;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
/** 400 — caller sent invalid data. */
|
||||
export class ValidationError extends AppError {
|
||||
constructor(message: string, code = 'VALIDATION_ERROR') {
|
||||
super(message, 400, code);
|
||||
}
|
||||
}
|
||||
|
||||
/** 401 — request is not authenticated. */
|
||||
export class UnauthenticatedError extends AppError {
|
||||
constructor(message = 'Authentication required') {
|
||||
super(message, 401, 'UNAUTHENTICATED');
|
||||
}
|
||||
}
|
||||
|
||||
/** 403 — authenticated but not permitted. */
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor(message = 'You do not have permission to perform this action') {
|
||||
super(message, 403, 'FORBIDDEN');
|
||||
}
|
||||
}
|
||||
|
||||
/** 404 — requested resource does not exist. */
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(resource: string, id?: string) {
|
||||
super(id ? `${resource} '${id}' not found` : `${resource} not found`, 404, 'NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
/** 409 — conflict with existing data. */
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message: string) {
|
||||
super(message, 409, 'CONFLICT');
|
||||
}
|
||||
}
|
||||
20
apps/api/src/lib/logger.ts
Normal file
20
apps/api/src/lib/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import pino from 'pino';
|
||||
import { env } from './env';
|
||||
|
||||
/**
|
||||
* Application-level Pino logger.
|
||||
* Uses pretty-printing in development, structured JSON in production.
|
||||
*/
|
||||
export const logger = pino({
|
||||
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
...(env.NODE_ENV !== 'production' && {
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'SYS:standard',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
17
apps/api/src/lib/require-auth.ts
Normal file
17
apps/api/src/lib/require-auth.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { UnauthenticatedError } from './errors';
|
||||
|
||||
/**
|
||||
* Fastify preHandler that enforces authentication.
|
||||
* Throws UnauthenticatedError (401) if no valid session is present.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* fastify.get('/me', { preHandler: [requireAuth] }, handler);
|
||||
* ```
|
||||
*/
|
||||
export async function requireAuth(request: FastifyRequest, _reply: FastifyReply): Promise<void> {
|
||||
if (!request.session) {
|
||||
throw new UnauthenticatedError();
|
||||
}
|
||||
}
|
||||
10
apps/api/src/lib/test-setup.ts
Normal file
10
apps/api/src/lib/test-setup.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Vitest global setup for apps/api.
|
||||
* Sets minimum required env vars before any test module is loaded.
|
||||
*/
|
||||
|
||||
process.env['NODE_ENV'] = 'test';
|
||||
process.env['DATABASE_URL'] = 'postgresql://test:test@localhost:5432/dwellops_test';
|
||||
process.env['BETTER_AUTH_SECRET'] = 'test-secret-at-least-32-characters-long!';
|
||||
process.env['BETTER_AUTH_URL'] = 'http://localhost:3001';
|
||||
process.env['PORT'] = '3001';
|
||||
41
apps/api/src/modules/auth/auth.routes.ts
Normal file
41
apps/api/src/modules/auth/auth.routes.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { auth } from '../../lib/auth';
|
||||
|
||||
/**
|
||||
* Auth module routes.
|
||||
*
|
||||
* All /api/auth/* requests are forwarded to the Better Auth handler.
|
||||
* Better Auth handles magic link, passkey, and optional OIDC flows.
|
||||
*
|
||||
* Better Auth uses Web standard Request/Response. We bridge from Fastify
|
||||
* via the Node.js http.IncomingMessage/ServerResponse adapters.
|
||||
*/
|
||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* Wildcard handler — forwards all /api/auth/* requests to Better Auth.
|
||||
* The `*` wildcard covers all sub-paths and methods.
|
||||
*/
|
||||
app.all('/api/auth/*', async (request, reply) => {
|
||||
const nodeHandler = auth.handler;
|
||||
|
||||
// Build a Web standard Request from the Fastify raw request.
|
||||
const url = `${request.protocol}://${request.hostname}${request.url}`;
|
||||
const webRequest = new Request(url, {
|
||||
method: request.method,
|
||||
headers: request.headers as Record<string, string>,
|
||||
body: ['GET', 'HEAD'].includes(request.method)
|
||||
? undefined
|
||||
: JSON.stringify(request.body),
|
||||
});
|
||||
|
||||
const response = await nodeHandler(webRequest);
|
||||
|
||||
reply.status(response.status);
|
||||
response.headers.forEach((value, key) => {
|
||||
reply.header(key, value);
|
||||
});
|
||||
|
||||
const body = await response.text();
|
||||
return reply.send(body);
|
||||
});
|
||||
}
|
||||
75
apps/api/src/modules/health/health.routes.ts
Normal file
75
apps/api/src/modules/health/health.routes.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '@dwellops/db';
|
||||
|
||||
/**
|
||||
* Health check module.
|
||||
* GET /health — shallow liveness
|
||||
* GET /health/ready — readiness including DB connectivity
|
||||
*/
|
||||
export async function healthRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get(
|
||||
'/health',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Health'],
|
||||
summary: 'Liveness probe',
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string' },
|
||||
timestamp: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (_req, reply) => {
|
||||
return reply.send({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/health/ready',
|
||||
{
|
||||
schema: {
|
||||
tags: ['Health'],
|
||||
summary: 'Readiness probe — includes database check',
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string' },
|
||||
db: { type: 'string' },
|
||||
timestamp: { type: 'string' },
|
||||
},
|
||||
},
|
||||
503: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string' },
|
||||
db: { type: 'string' },
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (_req, reply) => {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
return reply.send({
|
||||
status: 'ok',
|
||||
db: 'connected',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
return reply.status(503).send({
|
||||
status: 'error',
|
||||
db: 'unavailable',
|
||||
error: String(err instanceof Error ? err.message : err),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
71
apps/api/src/modules/health/health.test.ts
Normal file
71
apps/api/src/modules/health/health.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { healthRoutes } from './health.routes';
|
||||
|
||||
// vi.mock is hoisted — factory must not reference outer variables.
|
||||
vi.mock('@dwellops/db', () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ 1: 1 }]),
|
||||
},
|
||||
}));
|
||||
|
||||
// Access the mock AFTER the vi.mock call.
|
||||
const { prisma } = await import('@dwellops/db');
|
||||
|
||||
/**
|
||||
* Returns a fresh Fastify instance with health routes registered.
|
||||
* A new instance is required per test because Fastify cannot register
|
||||
* plugins onto an already-booted server.
|
||||
*/
|
||||
async function buildTestApp() {
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(healthRoutes);
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('Health routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([{ 1: 1 }]);
|
||||
});
|
||||
|
||||
it('GET /health returns 200 with status ok', async () => {
|
||||
const app = await buildTestApp();
|
||||
const response = await app.inject({ method: 'GET', url: '/health' });
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json<{ status: string }>();
|
||||
expect(body.status).toBe('ok');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /health/ready returns 200 when DB is up', async () => {
|
||||
const app = await buildTestApp();
|
||||
const response = await app.inject({ method: 'GET', url: '/health/ready' });
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json<{ db: string }>();
|
||||
expect(body.db).toBe('connected');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /health/ready returns 503 when DB is down with an Error', async () => {
|
||||
vi.mocked(prisma.$queryRaw).mockRejectedValueOnce(new Error('Connection refused'));
|
||||
const app = await buildTestApp();
|
||||
const response = await app.inject({ method: 'GET', url: '/health/ready' });
|
||||
expect(response.statusCode).toBe(503);
|
||||
const body = response.json<{ status: string; db: string; error: string }>();
|
||||
expect(body.status).toBe('error');
|
||||
expect(body.db).toBe('unavailable');
|
||||
expect(body.error).toBe('Connection refused');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /health/ready returns 503 when DB is down with a non-Error', async () => {
|
||||
vi.mocked(prisma.$queryRaw).mockRejectedValueOnce('string error');
|
||||
const app = await buildTestApp();
|
||||
const response = await app.inject({ method: 'GET', url: '/health/ready' });
|
||||
expect(response.statusCode).toBe(503);
|
||||
const body = response.json<{ status: string }>();
|
||||
expect(body.status).toBe('error');
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
105
apps/api/src/modules/hoa/hoa.routes.ts
Normal file
105
apps/api/src/modules/hoa/hoa.routes.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { requireAuth } from '../../lib/require-auth';
|
||||
import { NotFoundError, ForbiddenError } from '../../lib/errors';
|
||||
import { prisma } from '@dwellops/db';
|
||||
|
||||
/**
|
||||
* HOA module routes — demonstrates a permission-aware, audited route structure.
|
||||
*
|
||||
* Pattern:
|
||||
* - Thin route handlers: validate → check auth/permissions → call service → shape response.
|
||||
* - Business logic lives in services, not here.
|
||||
*/
|
||||
export async function hoaRoutes(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* GET /api/hoas — list HOAs the authenticated user is a member of.
|
||||
*/
|
||||
app.get(
|
||||
'/api/hoas',
|
||||
{
|
||||
preHandler: [requireAuth],
|
||||
schema: {
|
||||
tags: ['HOA'],
|
||||
summary: 'List HOAs for the current user',
|
||||
security: [{ sessionCookie: [] }],
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
slug: { type: 'string' },
|
||||
role: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = request.session!.user.id;
|
||||
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: { userId },
|
||||
include: { hoa: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
type MembershipWithHoa = (typeof memberships)[number];
|
||||
return reply.send({
|
||||
data: memberships.map((m: MembershipWithHoa) => ({
|
||||
id: m.hoa.id,
|
||||
name: m.hoa.name,
|
||||
slug: m.hoa.slug,
|
||||
role: m.role,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/hoas/:hoaId — get HOA details (member or admin only).
|
||||
*/
|
||||
app.get(
|
||||
'/api/hoas/:hoaId',
|
||||
{
|
||||
preHandler: [requireAuth],
|
||||
schema: {
|
||||
tags: ['HOA'],
|
||||
summary: 'Get HOA by ID',
|
||||
security: [{ sessionCookie: [] }],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: { hoaId: { type: 'string' } },
|
||||
required: ['hoaId'],
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { hoaId } = request.params as { hoaId: string };
|
||||
const userId = request.session!.user.id;
|
||||
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: { userId, hoaId },
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenError();
|
||||
}
|
||||
|
||||
const hoa = await prisma.hoa.findUnique({ where: { id: hoaId } });
|
||||
if (!hoa) {
|
||||
throw new NotFoundError('HOA', hoaId);
|
||||
}
|
||||
|
||||
return reply.send({ data: hoa });
|
||||
},
|
||||
);
|
||||
}
|
||||
45
apps/api/src/plugins/auth.ts
Normal file
45
apps/api/src/plugins/auth.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import { auth } from '../lib/auth';
|
||||
import type { RequestSession } from '@dwellops/types';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
/** Resolved session from Better Auth, or null if unauthenticated. */
|
||||
session: RequestSession | null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fastify plugin that resolves the Better Auth session on every request
|
||||
* and attaches it to `request.session`.
|
||||
*
|
||||
* Does NOT enforce authentication — individual routes must check
|
||||
* `request.session` or use the `requireAuth` preHandler.
|
||||
*/
|
||||
export const authPlugin = fp(async (app: FastifyInstance) => {
|
||||
app.decorateRequest('session', null);
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers as unknown as Headers,
|
||||
});
|
||||
if (session) {
|
||||
request.session = {
|
||||
user: {
|
||||
id: session.user.id as RequestSession['user']['id'],
|
||||
email: session.user.email,
|
||||
name: session.user.name ?? null,
|
||||
emailVerified: session.user.emailVerified,
|
||||
image: session.user.image ?? null,
|
||||
},
|
||||
sessionId: session.session.id,
|
||||
expiresAt: session.session.expiresAt,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// No valid session — leave request.session as null.
|
||||
}
|
||||
});
|
||||
});
|
||||
15
apps/api/src/plugins/cors.ts
Normal file
15
apps/api/src/plugins/cors.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { env } from '../lib/env';
|
||||
|
||||
/**
|
||||
* Registers CORS plugin.
|
||||
* Origin is controlled by the CORS_ORIGIN environment variable.
|
||||
*/
|
||||
export async function corsPlugin(app: FastifyInstance): Promise<void> {
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN.split(',').map((o) => o.trim()),
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
});
|
||||
}
|
||||
41
apps/api/src/plugins/swagger.ts
Normal file
41
apps/api/src/plugins/swagger.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import swagger from '@fastify/swagger';
|
||||
import swaggerUi from '@fastify/swagger-ui';
|
||||
|
||||
/**
|
||||
* Registers Swagger and Swagger UI plugins.
|
||||
* API docs are served at /documentation.
|
||||
*/
|
||||
export async function swaggerPlugin(app: FastifyInstance): Promise<void> {
|
||||
await app.register(swagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: 'DwellOps API',
|
||||
description: 'HOA management platform API',
|
||||
version: '1.0.0',
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
sessionCookie: {
|
||||
type: 'apiKey',
|
||||
in: 'cookie',
|
||||
name: 'better-auth.session',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(swaggerUi, {
|
||||
routePrefix: '/documentation',
|
||||
uiConfig: {
|
||||
docExpansion: 'list',
|
||||
deepLinking: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
47
apps/api/src/services/audit.service.ts
Normal file
47
apps/api/src/services/audit.service.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { db as DbType } from '@dwellops/db';
|
||||
import type { AuditAction } from '@dwellops/types';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
export interface AuditLogInput {
|
||||
userId?: string;
|
||||
action: AuditAction | string;
|
||||
entityType: string;
|
||||
entityId?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
ipAddress?: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service responsible for recording audit log entries.
|
||||
*
|
||||
* All destructive or sensitive actions must be recorded here.
|
||||
* The service never throws — failures are logged but do not
|
||||
* propagate to prevent audit failures from blocking primary operations.
|
||||
*/
|
||||
export class AuditService {
|
||||
constructor(private readonly db: typeof DbType) {}
|
||||
|
||||
/**
|
||||
* Records an audit log entry.
|
||||
*
|
||||
* @param input - The audit log entry data.
|
||||
*/
|
||||
async record(input: AuditLogInput): Promise<void> {
|
||||
try {
|
||||
await this.db.auditLog.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
action: input.action,
|
||||
entityType: input.entityType,
|
||||
entityId: input.entityId,
|
||||
payload: (input.payload ?? undefined) as object | undefined,
|
||||
ipAddress: input.ipAddress,
|
||||
userAgent: input.userAgent,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err, input }, 'Failed to write audit log entry');
|
||||
}
|
||||
}
|
||||
}
|
||||
12
apps/api/tsconfig.json
Normal file
12
apps/api/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@dwellops/config/tsconfig/node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
apps/api/tsconfig.tsbuildinfo
Normal file
1
apps/api/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
26
apps/api/vitest.config.ts
Normal file
26
apps/api/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
setupFiles: ['./src/lib/test-setup.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
thresholds: {
|
||||
lines: 85,
|
||||
functions: 85,
|
||||
branches: 85,
|
||||
statements: 85,
|
||||
},
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/index.ts',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
3
apps/web/.env.example
Normal file
3
apps/web/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
BETTER_AUTH_URL=http://localhost:3001
|
||||
BETTER_AUTH_SECRET="change-me-in-production-use-openssl-rand-base64-32"
|
||||
24
apps/web/.storybook/main.ts
Normal file
24
apps/web/.storybook/main.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { StorybookConfig } from '@storybook/nextjs-vite';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ['../src/**/*.stories.@(ts|tsx)', '../../packages/ui/src/**/*.stories.@(ts|tsx)'],
|
||||
addons: ['@storybook/addon-docs'],
|
||||
framework: {
|
||||
name: '@storybook/nextjs-vite',
|
||||
options: {},
|
||||
},
|
||||
docs: {
|
||||
autodocs: 'tag',
|
||||
},
|
||||
viteFinal(config) {
|
||||
config.resolve ??= {};
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'@': resolve(import.meta.dirname, '../src'),
|
||||
};
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
18
apps/web/.storybook/preview.ts
Normal file
18
apps/web/.storybook/preview.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Preview } from '@storybook/react';
|
||||
import '../src/styles/globals.css';
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: {
|
||||
default: 'page',
|
||||
values: [
|
||||
{ name: 'page', value: '#f8f9fa' },
|
||||
{ name: 'surface', value: '#ffffff' },
|
||||
{ name: 'dark', value: '#212529' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
2
apps/web/.stylelintrc.js
Normal file
2
apps/web/.stylelintrc.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import config from '@dwellops/config/stylelint';
|
||||
export default config;
|
||||
30
apps/web/e2e/dashboard.spec.ts
Normal file
30
apps/web/e2e/dashboard.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Smoke tests for the dashboard shell.
|
||||
* These tests verify the foundational page renders and is accessible.
|
||||
*/
|
||||
test.describe('Dashboard', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to the dashboard — locale redirect happens automatically.
|
||||
await page.goto('/en/dashboard');
|
||||
});
|
||||
|
||||
test('renders the dashboard page title', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeVisible();
|
||||
});
|
||||
|
||||
test('renders the stats grid section', async ({ page }) => {
|
||||
await expect(page.getByRole('region', { name: 'Summary statistics' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('page has correct document title', async ({ page }) => {
|
||||
await expect(page).toHaveTitle(/DwellOps/);
|
||||
});
|
||||
|
||||
test('page has no critical accessibility violations', async ({ page }) => {
|
||||
// Basic landmark checks — full axe integration should be added in CI.
|
||||
await expect(page.getByRole('main')).toBeVisible();
|
||||
await expect(page.getByRole('banner')).toBeVisible();
|
||||
});
|
||||
});
|
||||
3
apps/web/eslint.config.js
Normal file
3
apps/web/eslint.config.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { react } from '@dwellops/config/eslint';
|
||||
|
||||
export default react;
|
||||
8
apps/web/i18n/navigation.ts
Normal file
8
apps/web/i18n/navigation.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createNavigation } from 'next-intl/navigation';
|
||||
import { routing } from './routing';
|
||||
|
||||
/**
|
||||
* Type-safe locale-aware navigation helpers.
|
||||
* Use these instead of the plain Next.js Link and useRouter.
|
||||
*/
|
||||
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);
|
||||
16
apps/web/i18n/request.ts
Normal file
16
apps/web/i18n/request.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { getRequestConfig } from 'next-intl/server';
|
||||
import { routing } from './routing';
|
||||
|
||||
/**
|
||||
* Per-request next-intl configuration.
|
||||
* Loads the aggregated messages file for the resolved locale.
|
||||
*/
|
||||
export default getRequestConfig(async ({ requestLocale }) => {
|
||||
const locale = (await requestLocale) ?? routing.defaultLocale;
|
||||
|
||||
return {
|
||||
locale,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
messages: (await import(`../messages/${locale}.json`)).default,
|
||||
};
|
||||
});
|
||||
12
apps/web/i18n/routing.ts
Normal file
12
apps/web/i18n/routing.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineRouting } from 'next-intl/routing';
|
||||
import { locales, defaultLocale } from '@dwellops/i18n';
|
||||
|
||||
/**
|
||||
* next-intl routing configuration.
|
||||
* Locale-prefixed routes: /en/dashboard, etc.
|
||||
*/
|
||||
export const routing = defineRouting({
|
||||
locales,
|
||||
defaultLocale,
|
||||
localePrefix: 'always',
|
||||
});
|
||||
45
apps/web/messages/en.json
Normal file
45
apps/web/messages/en.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "DwellOps",
|
||||
"loading": "Loading…",
|
||||
"error": "Something went wrong.",
|
||||
"retry": "Try again",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"close": "Close",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"units": "Units",
|
||||
"residents": "Residents",
|
||||
"documents": "Documents",
|
||||
"settings": "Settings",
|
||||
"signOut": "Sign out"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"welcome": "Welcome back, {name}",
|
||||
"stats": {
|
||||
"totalUnits": "Total units",
|
||||
"activeResidents": "Active residents",
|
||||
"pendingRequests": "Pending requests",
|
||||
"openIssues": "Open issues"
|
||||
},
|
||||
"recentActivity": "Recent activity",
|
||||
"noActivity": "No recent activity to display."
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Sign in",
|
||||
"signInWithMagicLink": "Sign in with magic link",
|
||||
"signInWithPasskey": "Sign in with passkey",
|
||||
"enterEmail": "Enter your email",
|
||||
"emailPlaceholder": "you@example.com",
|
||||
"magicLinkSent": "Check your email — we sent a magic link.",
|
||||
"passkeyPrompt": "Use your passkey to authenticate."
|
||||
}
|
||||
}
|
||||
13
apps/web/next.config.ts
Normal file
13
apps/web/next.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { NextConfig } from 'next';
|
||||
import createNextIntlPlugin from 'next-intl/plugin';
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
|
||||
|
||||
const config: NextConfig = {
|
||||
experimental: {
|
||||
// Enables importing packages that export CSS directly
|
||||
optimizePackageImports: ['@dwellops/ui'],
|
||||
},
|
||||
};
|
||||
|
||||
export default withNextIntl(config);
|
||||
60
apps/web/package.json
Normal file
60
apps/web/package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@dwellops/web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src && stylelint \"src/**/*.module.css\"",
|
||||
"lint:fix": "eslint src --fix && stylelint \"src/**/*.module.css\" --fix",
|
||||
"test": "vitest run --coverage",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"storybook:build": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"next-intl": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"@dwellops/ui": "workspace:*",
|
||||
"@dwellops/types": "workspace:*",
|
||||
"@dwellops/schemas": "workspace:*",
|
||||
"@dwellops/i18n": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"postcss": "catalog:",
|
||||
"postcss-preset-env": "catalog:",
|
||||
"postcss-import": "catalog:",
|
||||
"stylelint": "catalog:",
|
||||
"stylelint-config-standard": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"typescript-eslint": "catalog:",
|
||||
"eslint-plugin-react": "catalog:",
|
||||
"eslint-plugin-react-hooks": "catalog:",
|
||||
"eslint-plugin-jsx-a11y": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitejs/plugin-react": "catalog:",
|
||||
"jsdom": "catalog:",
|
||||
"@testing-library/react": "catalog:",
|
||||
"@testing-library/user-event": "catalog:",
|
||||
"@testing-library/jest-dom": "catalog:",
|
||||
"@playwright/test": "catalog:",
|
||||
"storybook": "catalog:",
|
||||
"@storybook/nextjs-vite": "catalog:",
|
||||
"@storybook/addon-docs": "catalog:",
|
||||
"@storybook/react": "catalog:",
|
||||
"@dwellops/config": "workspace:*",
|
||||
"@dwellops/test-utils": "workspace:*"
|
||||
}
|
||||
}
|
||||
43
apps/web/playwright.config.ts
Normal file
43
apps/web/playwright.config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
const baseURL = process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:3000';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env['CI'],
|
||||
retries: process.env['CI'] ? 2 : 0,
|
||||
workers: process.env['CI'] ? 1 : undefined,
|
||||
reporter: [['html', { outputFolder: 'playwright-report' }]],
|
||||
use: {
|
||||
baseURL,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: { ...devices['Desktop Firefox'] },
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: { ...devices['Desktop Safari'] },
|
||||
},
|
||||
{
|
||||
name: 'Mobile Chrome',
|
||||
use: { ...devices['Pixel 5'] },
|
||||
},
|
||||
],
|
||||
webServer: process.env['CI']
|
||||
? undefined
|
||||
: {
|
||||
command: 'pnpm dev',
|
||||
url: baseURL,
|
||||
reuseExistingServer: true,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
18
apps/web/postcss.config.js
Normal file
18
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'postcss-import': {},
|
||||
'postcss-preset-env': {
|
||||
stage: 1,
|
||||
features: {
|
||||
'nesting-rules': true,
|
||||
'custom-properties': false, // already native in modern browsers
|
||||
'custom-media-queries': true,
|
||||
'media-query-ranges': true,
|
||||
},
|
||||
browsers: ['last 2 versions', 'not dead', 'not < 0.2%'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
22
apps/web/src/app/[locale]/dashboard/page.tsx
Normal file
22
apps/web/src/app/[locale]/dashboard/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Locale } from '@dwellops/i18n';
|
||||
import { DashboardView } from '@/views/DashboardView/DashboardView';
|
||||
|
||||
interface DashboardPageProps {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: DashboardPageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'dashboard' });
|
||||
return { title: t('title') };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard page — thin page component that delegates to the DashboardView.
|
||||
* Data fetching and layout composition happen inside the view.
|
||||
*/
|
||||
export default async function DashboardPage({ params }: DashboardPageProps) {
|
||||
const { locale } = await params;
|
||||
return <DashboardView locale={locale} />;
|
||||
}
|
||||
42
apps/web/src/app/[locale]/layout.tsx
Normal file
42
apps/web/src/app/[locale]/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { routing } from '../../../i18n/routing';
|
||||
import type { Locale } from '@dwellops/i18n';
|
||||
|
||||
interface LocaleLayoutProps {
|
||||
children: ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return routing.locales.map((locale) => ({ locale }));
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'DwellOps',
|
||||
};
|
||||
|
||||
/**
|
||||
* Locale-aware root layout.
|
||||
* Sets the HTML lang attribute and provides next-intl messages.
|
||||
*/
|
||||
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
|
||||
const { locale } = await params;
|
||||
|
||||
if (!routing.locales.includes(locale as Locale)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
<NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
14
apps/web/src/app/[locale]/page.tsx
Normal file
14
apps/web/src/app/[locale]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import type { Locale } from '@dwellops/i18n';
|
||||
|
||||
interface HomePageProps {
|
||||
params: Promise<{ locale: Locale }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale home page — redirects to the dashboard shell.
|
||||
*/
|
||||
export default async function HomePage({ params }: HomePageProps) {
|
||||
const { locale } = await params;
|
||||
redirect(`/${locale}/dashboard`);
|
||||
}
|
||||
23
apps/web/src/app/layout.tsx
Normal file
23
apps/web/src/app/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import '@/styles/globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: '%s | DwellOps',
|
||||
default: 'DwellOps',
|
||||
},
|
||||
description: 'Modern HOA management platform',
|
||||
};
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root layout — minimal shell that wraps the locale-specific layout.
|
||||
* Locale-aware layout is in [locale]/layout.tsx.
|
||||
*/
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return children;
|
||||
}
|
||||
36
apps/web/src/components/PageHeader/PageHeader.module.css
Normal file
36
apps/web/src/components/PageHeader/PageHeader.module.css
Normal file
@@ -0,0 +1,36 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
padding-block-end: var(--space-6);
|
||||
border-block-end: var(--border-width-default) solid var(--color-border-subtle);
|
||||
margin-block-end: var(--space-6);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
line-height: var(--line-height-tight);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
39
apps/web/src/components/PageHeader/PageHeader.stories.tsx
Normal file
39
apps/web/src/components/PageHeader/PageHeader.stories.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Button } from '@dwellops/ui/Button';
|
||||
import { PageHeader } from './PageHeader';
|
||||
|
||||
const meta: Meta<typeof PageHeader> = {
|
||||
title: 'Components/PageHeader',
|
||||
component: PageHeader,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Presentational page header for page-level views.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof PageHeader>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { title: 'Dashboard' },
|
||||
};
|
||||
|
||||
export const WithSubtitle: Story = {
|
||||
args: {
|
||||
title: 'Dashboard',
|
||||
subtitle: 'Sunrise Ridge HOA',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithActions: Story = {
|
||||
args: {
|
||||
title: 'Units',
|
||||
subtitle: '24 units total',
|
||||
actions: <Button variant="primary">Add unit</Button>,
|
||||
},
|
||||
};
|
||||
25
apps/web/src/components/PageHeader/PageHeader.test.tsx
Normal file
25
apps/web/src/components/PageHeader/PageHeader.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@dwellops/test-utils';
|
||||
import { PageHeader } from './PageHeader';
|
||||
|
||||
describe('PageHeader', () => {
|
||||
it('renders the title', () => {
|
||||
render(<PageHeader title="Dashboard" />);
|
||||
expect(screen.getByRole('heading', { name: 'Dashboard', level: 1 })).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders the subtitle when provided', () => {
|
||||
render(<PageHeader title="Dashboard" subtitle="Sunrise Ridge HOA" />);
|
||||
expect(screen.getByText('Sunrise Ridge HOA')).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not render subtitle when omitted', () => {
|
||||
render(<PageHeader title="Dashboard" />);
|
||||
expect(screen.queryByText('Sunrise Ridge HOA')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders actions slot', () => {
|
||||
render(<PageHeader title="Units" actions={<button>Add unit</button>} />);
|
||||
expect(screen.getByRole('button', { name: 'Add unit' })).toBeDefined();
|
||||
});
|
||||
});
|
||||
36
apps/web/src/components/PageHeader/PageHeader.tsx
Normal file
36
apps/web/src/components/PageHeader/PageHeader.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import styles from './PageHeader.module.css';
|
||||
|
||||
export interface PageHeaderProps {
|
||||
/** Primary heading text. */
|
||||
title: string;
|
||||
/** Optional subtitle or breadcrumb text. */
|
||||
subtitle?: string;
|
||||
/** Optional actions rendered in the header trailing area (e.g. buttons). */
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentational page header.
|
||||
*
|
||||
* Used at the top of page-level views. Does not fetch data or
|
||||
* perform navigation — pure presentation only.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <PageHeader title="Dashboard" subtitle="Sunrise Ridge HOA" />
|
||||
* ```
|
||||
*/
|
||||
export function PageHeader({ title, subtitle, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div className={styles.text}>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{subtitle && <p className={styles.subtitle}>{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className={styles.actions}>{actions}</div>}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageHeader;
|
||||
1
apps/web/src/components/PageHeader/translations.json
Normal file
1
apps/web/src/components/PageHeader/translations.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
5
apps/web/src/css.d.ts
vendored
Normal file
5
apps/web/src/css.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/** TypeScript declarations for CSS Module files. */
|
||||
declare module '*.module.css' {
|
||||
const styles: Record<string, string>;
|
||||
export default styles;
|
||||
}
|
||||
8
apps/web/src/lib/test-setup.ts
Normal file
8
apps/web/src/lib/test-setup.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
// next-intl requires these globals in test environments.
|
||||
// Provide minimal stubs.
|
||||
Object.defineProperty(globalThis, '__NI18N_LOCALE', {
|
||||
value: 'en',
|
||||
writable: true,
|
||||
});
|
||||
13
apps/web/src/middleware.ts
Normal file
13
apps/web/src/middleware.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import createMiddleware from 'next-intl/middleware';
|
||||
import { routing } from '../i18n/routing';
|
||||
|
||||
/**
|
||||
* next-intl middleware for locale detection and routing.
|
||||
* Redirects / to /en (or detected locale).
|
||||
*/
|
||||
export default createMiddleware(routing);
|
||||
|
||||
export const config = {
|
||||
// Match all routes except Next.js internals and static files.
|
||||
matcher: ['/((?!_next|_vercel|.*\\..*).*)'],
|
||||
};
|
||||
69
apps/web/src/styles/globals.css
Normal file
69
apps/web/src/styles/globals.css
Normal file
@@ -0,0 +1,69 @@
|
||||
/* Design token import — must be first. */
|
||||
@import '@dwellops/ui/tokens';
|
||||
|
||||
/* Viewport-aware base reset */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-family-base);
|
||||
font-size: 100%;
|
||||
line-height: var(--line-height-normal);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
hanging-punctuation: first last;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg-page);
|
||||
color: var(--color-text-primary);
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* Smooth focus transitions, preserve reduced motion preference */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:focus-visible {
|
||||
transition: box-shadow var(--transition-fast);
|
||||
}
|
||||
}
|
||||
|
||||
/* Default focus ring — overridable per component */
|
||||
:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
img,
|
||||
svg,
|
||||
video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Screen-reader-only utility */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
26
apps/web/src/views/DashboardView/DashboardView.module.css
Normal file
26
apps/web/src/views/DashboardView/DashboardView.module.css
Normal file
@@ -0,0 +1,26 @@
|
||||
.main {
|
||||
min-height: 100dvh;
|
||||
background-color: var(--color-bg-page);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin-inline: auto;
|
||||
padding: var(--space-8) var(--space-6);
|
||||
}
|
||||
|
||||
.activity {
|
||||
margin-block-start: var(--space-8);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
margin-block-end: var(--space-4);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
45
apps/web/src/views/DashboardView/DashboardView.tsx
Normal file
45
apps/web/src/views/DashboardView/DashboardView.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import type { Locale } from '@dwellops/i18n';
|
||||
import { PageHeader } from '@/components/PageHeader/PageHeader';
|
||||
import { DashboardStats } from '@/widgets/DashboardStats/DashboardStats';
|
||||
import styles from './DashboardView.module.css';
|
||||
|
||||
interface DashboardViewProps {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard page-level view.
|
||||
*
|
||||
* Views are server components by default. They orchestrate data loading
|
||||
* and compose components and widgets into the full page layout.
|
||||
* Views do not contain reusable primitive logic.
|
||||
*/
|
||||
export async function DashboardView({ locale }: DashboardViewProps) {
|
||||
const t = await getTranslations({ locale, namespace: 'dashboard' });
|
||||
|
||||
// In a real app this data would come from the API layer.
|
||||
const stats = [
|
||||
{ labelKey: 'totalUnits' as const, value: 24 },
|
||||
{ labelKey: 'activeResidents' as const, value: 87 },
|
||||
{ labelKey: 'pendingRequests' as const, value: 3 },
|
||||
{ labelKey: 'openIssues' as const, value: 7 },
|
||||
];
|
||||
|
||||
return (
|
||||
<main className={styles.main}>
|
||||
<div className={styles.container}>
|
||||
<PageHeader title={t('title')} />
|
||||
<DashboardStats stats={stats} />
|
||||
<section className={styles.activity} aria-labelledby="activity-heading">
|
||||
<h2 id="activity-heading" className={styles.sectionTitle}>
|
||||
{t('recentActivity')}
|
||||
</h2>
|
||||
<p className={styles.empty}>{t('noActivity')}</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardView;
|
||||
@@ -0,0 +1,25 @@
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
line-height: var(--line-height-tight);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { DashboardStats } from './DashboardStats';
|
||||
|
||||
const meta: Meta<typeof DashboardStats> = {
|
||||
title: 'Widgets/DashboardStats',
|
||||
component: DashboardStats,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Widget that renders summary stat cards for the dashboard. Composes the Card primitive.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DashboardStats>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
stats: [
|
||||
{ labelKey: 'totalUnits', value: 24 },
|
||||
{ labelKey: 'activeResidents', value: 87 },
|
||||
{ labelKey: 'pendingRequests', value: 3 },
|
||||
{ labelKey: 'openIssues', value: 7 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { stats: [] },
|
||||
};
|
||||
48
apps/web/src/widgets/DashboardStats/DashboardStats.tsx
Normal file
48
apps/web/src/widgets/DashboardStats/DashboardStats.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Card } from '@dwellops/ui/Card';
|
||||
import styles from './DashboardStats.module.css';
|
||||
|
||||
export interface StatItem {
|
||||
/** i18n key for the stat label, relative to `dashboard.stats`. */
|
||||
labelKey: 'totalUnits' | 'activeResidents' | 'pendingRequests' | 'openIssues';
|
||||
value: number | string;
|
||||
}
|
||||
|
||||
export interface DashboardStatsProps {
|
||||
stats: StatItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Widget that renders a grid of summary stat cards.
|
||||
*
|
||||
* Widgets compose components and may contain local state.
|
||||
* They do not make API calls — data is passed in as props.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DashboardStats stats={[
|
||||
* { labelKey: 'totalUnits', value: 24 },
|
||||
* { labelKey: 'activeResidents', value: 87 },
|
||||
* ]} />
|
||||
* ```
|
||||
*/
|
||||
export function DashboardStats({ stats }: DashboardStatsProps) {
|
||||
const t = useTranslations('dashboard.stats');
|
||||
|
||||
return (
|
||||
<section aria-label="Summary statistics" className={styles.grid}>
|
||||
{stats.map((stat) => (
|
||||
<Card key={stat.labelKey}>
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.value}>{stat.value}</span>
|
||||
<span className={styles.label}>{t(stat.labelKey)}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardStats;
|
||||
10
apps/web/tsconfig.json
Normal file
10
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@dwellops/config/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "i18n", "next-env.d.ts", "next.config.ts", ".next/types/**/*.ts"]
|
||||
}
|
||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
35
apps/web/vitest.config.ts
Normal file
35
apps/web/vitest.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/lib/test-setup.ts'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**', '**/*.spec.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
thresholds: {
|
||||
lines: 85,
|
||||
functions: 85,
|
||||
branches: 85,
|
||||
statements: 85,
|
||||
},
|
||||
exclude: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.*',
|
||||
'**/*.stories.*',
|
||||
],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(import.meta.dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user