mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 13:51:45 +00:00
Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer
Brownfield strangler migration of the backend onto NestJS modules (auth, trips, days, places, assignments, packing, todo, budget, reservations, collab, files, photos, journey, share, settings, backup, oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories, tags, notifications, system-notices) served through a per-prefix dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT httpOnly cookie auth, with behavioural parity for every route. Client: React 19 upgrade, "page = wiring container + data hook" pattern across all pages, per-domain Zustand stores bound to @trek/shared contracts, and decomposition of the large components (DayPlanSidebar, PackingListPanel, CollabNotes, FileManager, MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal, BudgetPanel, PlaceFormModal, ...) into focused render units backed by in-file hooks. Apply the shared global request pipeline (helmet/CSP, CORS, HSTS, forced HTTPS, the global MFA policy and request logging) to the NestJS instance as well, so a migrated route is protected identically to the legacy fallback rather than bypassing it.
This commit is contained in:
+8
-5
@@ -42,15 +42,15 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"src/**/*.ts\"",
|
||||
"format:check": "prettier --check \"src/**/*.ts\"",
|
||||
"lint": "eslint --fix \"src/**/*.ts\""
|
||||
"lint": "eslint --fix \"src/**/*.ts\"",
|
||||
"i18n:parity": "node scripts/i18n-parity.mjs",
|
||||
"i18n:parity:strict": "node scripts/i18n-parity.mjs --strict --files-only"
|
||||
},
|
||||
"dependencies": {
|
||||
"isomorphic-dompurify": "^3.15.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^6.0.2",
|
||||
"vitest": "^3.2.4",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"eslint": "^10.3.0",
|
||||
@@ -59,6 +59,9 @@
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"typescript-eslint": "^8.58.2"
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
// i18n parity check — keeps Julien's "every locale = same files + keys as en"
|
||||
// DoD honest over time.
|
||||
//
|
||||
// What it checks per non-en locale:
|
||||
// 1. File set parity: every domain file that exists in en/ must exist in this
|
||||
// locale's dir; no extra domain files allowed.
|
||||
// 2. Key set parity: for each shared domain file, the top-level translation
|
||||
// keys must match exactly (no missing, no extra).
|
||||
//
|
||||
// Output: structured report grouped by locale, plus a `--strict` flag that
|
||||
// returns exit-code 1 when any drift is present (intended for CI). Without
|
||||
// `--strict` the script exits 0 and prints, so it can also run as a non-blocking
|
||||
// audit during translation work.
|
||||
//
|
||||
// Limitations: we only parse *top-level* string keys (those declared as the
|
||||
// first column of the file, matching the regex below). Nested objects, function
|
||||
// bodies, and inline comments are ignored. This matches how `t(key)` calls
|
||||
// resolve at runtime in TranslationContext.
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const i18nRoot = join(here, '..', 'src', 'i18n');
|
||||
|
||||
// Match a top-level translation key declaration: leading whitespace, then a
|
||||
// quoted key (must start with a lowercase letter), then a colon. This is the
|
||||
// exact pattern every domain file uses.
|
||||
const TOP_LEVEL_KEY_RE = /^\s*'([a-z][a-zA-Z0-9.\-_]*)'\s*:/gm;
|
||||
|
||||
function listLocales() {
|
||||
return readdirSync(i18nRoot)
|
||||
.filter((name) => statSync(join(i18nRoot, name)).isDirectory())
|
||||
// externalNotifications is a barrel module, not a locale.
|
||||
.filter((name) => name !== 'externalNotifications');
|
||||
}
|
||||
|
||||
function listDomainFiles(locale) {
|
||||
return readdirSync(join(i18nRoot, locale))
|
||||
.filter((f) => f.endsWith('.ts') && f !== 'index.ts')
|
||||
.sort();
|
||||
}
|
||||
|
||||
function extractKeys(locale, file) {
|
||||
const content = readFileSync(join(i18nRoot, locale, file), 'utf8');
|
||||
const keys = new Set();
|
||||
for (const match of content.matchAll(TOP_LEVEL_KEY_RE)) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function diffSets(reference, candidate) {
|
||||
const missing = [];
|
||||
const extra = [];
|
||||
for (const k of reference) if (!candidate.has(k)) missing.push(k);
|
||||
for (const k of candidate) if (!reference.has(k)) extra.push(k);
|
||||
return { missing, extra };
|
||||
}
|
||||
|
||||
function checkParity() {
|
||||
const locales = listLocales();
|
||||
if (!locales.includes('en')) {
|
||||
throw new Error('shared/src/i18n/en is required as the reference locale');
|
||||
}
|
||||
const enFiles = listDomainFiles('en');
|
||||
const enKeysByDomain = new Map();
|
||||
for (const f of enFiles) enKeysByDomain.set(f, extractKeys('en', f));
|
||||
|
||||
const report = { fileDrift: [], keyDrift: [] };
|
||||
|
||||
for (const locale of locales) {
|
||||
if (locale === 'en') continue;
|
||||
|
||||
const localeFiles = listDomainFiles(locale);
|
||||
const { missing: missingFiles, extra: extraFiles } = diffSets(
|
||||
new Set(enFiles),
|
||||
new Set(localeFiles),
|
||||
);
|
||||
|
||||
if (missingFiles.length || extraFiles.length) {
|
||||
report.fileDrift.push({ locale, missing: missingFiles, extra: extraFiles });
|
||||
}
|
||||
|
||||
for (const file of enFiles) {
|
||||
if (!localeFiles.includes(file)) continue;
|
||||
const localeKeys = extractKeys(locale, file);
|
||||
const { missing, extra } = diffSets(enKeysByDomain.get(file), localeKeys);
|
||||
if (missing.length || extra.length) {
|
||||
report.keyDrift.push({ locale, file, missing, extra });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
function formatReport(report) {
|
||||
const lines = [];
|
||||
|
||||
if (report.fileDrift.length === 0) {
|
||||
lines.push('File parity: OK');
|
||||
} else {
|
||||
lines.push(`File parity: ${report.fileDrift.length} locale(s) with file drift`);
|
||||
for (const { locale, missing, extra } of report.fileDrift) {
|
||||
if (missing.length) lines.push(` ${locale}: missing ${missing.join(', ')}`);
|
||||
if (extra.length) lines.push(` ${locale}: extra ${extra.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (report.keyDrift.length === 0) {
|
||||
lines.push('Key parity: OK');
|
||||
} else {
|
||||
lines.push(`Key parity: ${report.keyDrift.length} domain file(s) with key drift`);
|
||||
for (const { locale, file, missing, extra } of report.keyDrift) {
|
||||
const parts = [];
|
||||
if (missing.length) parts.push(`missing ${missing.length} (e.g. ${missing.slice(0, 3).join(', ')})`);
|
||||
if (extra.length) parts.push(`extra ${extra.length} (e.g. ${extra.slice(0, 3).join(', ')})`);
|
||||
lines.push(` ${locale}/${file}: ${parts.join('; ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// Export a structured API for vitest. The CLI entry point only runs when
|
||||
// executed directly (`node scripts/i18n-parity.mjs`), so importing this file
|
||||
// from a spec does not produce side effects.
|
||||
export { checkParity, formatReport };
|
||||
|
||||
const isCli = process.argv[1] && process.argv[1].endsWith('i18n-parity.mjs');
|
||||
if (isCli) {
|
||||
const strict = process.argv.includes('--strict');
|
||||
const filesOnly = process.argv.includes('--files-only');
|
||||
const report = checkParity();
|
||||
process.stdout.write(formatReport(filesOnly ? { ...report, keyDrift: [] } : report) + '\n');
|
||||
|
||||
if (strict) {
|
||||
const hasFileDrift = report.fileDrift.length > 0;
|
||||
const hasKeyDrift = filesOnly ? false : report.keyDrift.length > 0;
|
||||
if (hasFileDrift || hasKeyDrift) process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { adminUserCreateRequestSchema, adminPermissionsRequestSchema, adminInviteCreateRequestSchema, adminFeatureToggleRequestSchema } from './admin.schema';
|
||||
|
||||
describe('adminUserCreateRequestSchema', () => {
|
||||
it('requires an email; role limited to user/admin', () => {
|
||||
expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c', password: 'p', role: 'admin' }).success).toBe(true);
|
||||
expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(true);
|
||||
expect(adminUserCreateRequestSchema.safeParse({ password: 'p' }).success).toBe(false);
|
||||
expect(adminUserCreateRequestSchema.safeParse({ email: 'a@b.c', role: 'root' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adminPermissionsRequestSchema', () => {
|
||||
it('requires a permissions record', () => {
|
||||
expect(adminPermissionsRequestSchema.safeParse({ permissions: { trip_edit: { user: true } } }).success).toBe(true);
|
||||
expect(adminPermissionsRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adminInviteCreateRequestSchema', () => {
|
||||
it('accepts optional uses/expiry/role', () => {
|
||||
expect(adminInviteCreateRequestSchema.safeParse({ max_uses: 5, expires_in_days: 7 }).success).toBe(true);
|
||||
expect(adminInviteCreateRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(adminInviteCreateRequestSchema.safeParse({ role: 'root' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adminFeatureToggleRequestSchema', () => {
|
||||
it('requires a boolean enabled', () => {
|
||||
expect(adminFeatureToggleRequestSchema.safeParse({ enabled: true }).success).toBe(true);
|
||||
expect(adminFeatureToggleRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Admin API contract for /api/admin (admin-only).
|
||||
*
|
||||
* The admin service validates most bodies itself (returning {error,status}), so
|
||||
* these schemas pin the well-defined ones: user create/update, the permission
|
||||
* matrix, invites and the boolean feature toggles. Free-form bodies (OIDC
|
||||
* settings, addon config, default user settings) stay with the service.
|
||||
*/
|
||||
export const adminUserCreateRequestSchema = z.object({
|
||||
email: z.string(),
|
||||
password: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
role: z.enum(['user', 'admin']).optional(),
|
||||
});
|
||||
export type AdminUserCreateRequest = z.infer<typeof adminUserCreateRequestSchema>;
|
||||
|
||||
export const adminPermissionsRequestSchema = z.object({
|
||||
permissions: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
export type AdminPermissionsRequest = z.infer<typeof adminPermissionsRequestSchema>;
|
||||
|
||||
export const adminInviteCreateRequestSchema = z.object({
|
||||
max_uses: z.number().optional(),
|
||||
expires_in_days: z.number().optional(),
|
||||
role: z.enum(['user', 'admin']).optional(),
|
||||
});
|
||||
export type AdminInviteCreateRequest = z.infer<typeof adminInviteCreateRequestSchema>;
|
||||
|
||||
export const adminFeatureToggleRequestSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
export type AdminFeatureToggleRequest = z.infer<typeof adminFeatureToggleRequestSchema>;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { airportSchema, airportSearchQuerySchema } from './airport.schema';
|
||||
|
||||
describe('airportSchema', () => {
|
||||
it('accepts a full airport record', () => {
|
||||
const parsed = airportSchema.parse({
|
||||
iata: 'BER', icao: 'EDDB', name: 'Berlin Brandenburg', city: 'Berlin',
|
||||
country: 'DE', lat: 52.36, lng: 13.5, tz: 'Europe/Berlin',
|
||||
});
|
||||
expect(parsed.iata).toBe('BER');
|
||||
});
|
||||
|
||||
it('allows a null icao (smaller fields can be missing one)', () => {
|
||||
expect(airportSchema.safeParse({
|
||||
iata: 'XXX', icao: null, name: 'Test', city: 'Test', country: 'DE',
|
||||
lat: 0, lng: 0, tz: 'UTC',
|
||||
}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('airportSearchQuerySchema', () => {
|
||||
it('treats the query as optional (the route answers [] when absent)', () => {
|
||||
expect(airportSearchQuerySchema.parse({})).toEqual({});
|
||||
expect(airportSearchQuerySchema.parse({ q: 'ber' })).toEqual({ q: 'ber' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Airport API contract — single source of truth for the /api/airports endpoints.
|
||||
*
|
||||
* The legacy Express route (server/src/routes/airports.ts) exposes a typeahead
|
||||
* search and a single-airport lookup by IATA code, both backed by an in-memory
|
||||
* dataset (server/src/services/airportService.ts). The route treats the query as
|
||||
* an opaque string and returns an empty array when it is absent, so the search
|
||||
* query mirrors that: an optional string, no coercion.
|
||||
*
|
||||
* The bespoke 404 `{ error: 'Airport not found' }` body is reproduced in the
|
||||
* controller, not derived from this schema, so the response stays byte-identical
|
||||
* to Express.
|
||||
*/
|
||||
|
||||
/** A single airport record as served by the dataset (matches Airport in airportService). */
|
||||
export const airportSchema = z.object({
|
||||
iata: z.string(),
|
||||
icao: z.string().nullable(),
|
||||
name: z.string(),
|
||||
city: z.string(),
|
||||
country: z.string(),
|
||||
lat: z.number(),
|
||||
lng: z.number(),
|
||||
tz: z.string(),
|
||||
});
|
||||
export type Airport = z.infer<typeof airportSchema>;
|
||||
|
||||
/**
|
||||
* Search query. `q` is optional — the route answers with `[]` when it is missing
|
||||
* or empty rather than 400ing, so presence is handled in the controller.
|
||||
*/
|
||||
export const airportSearchQuerySchema = z.object({
|
||||
q: z.string().optional(),
|
||||
});
|
||||
export type AirportSearchQuery = z.infer<typeof airportSearchQuerySchema>;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
assignmentCreateRequestSchema,
|
||||
assignmentMoveRequestSchema,
|
||||
assignmentParticipantsRequestSchema,
|
||||
} from './assignment.schema';
|
||||
|
||||
describe('assignmentCreateRequestSchema', () => {
|
||||
it('requires a place_id; notes optional/nullable', () => {
|
||||
expect(assignmentCreateRequestSchema.safeParse({ place_id: 2 }).success).toBe(true);
|
||||
expect(assignmentCreateRequestSchema.safeParse({ place_id: '2', notes: null }).success).toBe(true);
|
||||
expect(assignmentCreateRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignmentMoveRequestSchema', () => {
|
||||
it('requires new_day_id; order_index optional', () => {
|
||||
expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4 }).success).toBe(true);
|
||||
expect(assignmentMoveRequestSchema.safeParse({ new_day_id: 4, order_index: 0 }).success).toBe(true);
|
||||
expect(assignmentMoveRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignmentParticipantsRequestSchema', () => {
|
||||
it('requires a numeric user_ids array', () => {
|
||||
expect(assignmentParticipantsRequestSchema.safeParse({ user_ids: [1, 2] }).success).toBe(true);
|
||||
expect(assignmentParticipantsRequestSchema.safeParse({ user_ids: 'no' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Assignment API contract — single source of truth for the place↔day itinerary
|
||||
* endpoints under /api/trips/:tripId/days/:dayId/assignments and
|
||||
* /api/trips/:tripId/assignments/:id/*.
|
||||
*
|
||||
* Trip-scoped; mutations use the 'day_edit' permission. The legacy route
|
||||
* (server/src/routes/assignments.ts, mounted on /api) wraps assignmentService.
|
||||
* Assignment rows carry joined place data and are kept open in responses; the
|
||||
* request schemas + the bespoke 404/400 controller messages pin the rest.
|
||||
*/
|
||||
|
||||
export const assignmentCreateRequestSchema = z.object({
|
||||
place_id: z.union([z.number(), z.string()]),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
export type AssignmentCreateRequest = z.infer<typeof assignmentCreateRequestSchema>;
|
||||
|
||||
export const assignmentReorderRequestSchema = z.object({
|
||||
orderedIds: z.array(z.number()),
|
||||
});
|
||||
export type AssignmentReorderRequest = z.infer<typeof assignmentReorderRequestSchema>;
|
||||
|
||||
export const assignmentMoveRequestSchema = z.object({
|
||||
new_day_id: z.union([z.number(), z.string()]),
|
||||
order_index: z.number().optional(),
|
||||
});
|
||||
export type AssignmentMoveRequest = z.infer<typeof assignmentMoveRequestSchema>;
|
||||
|
||||
export const assignmentTimeRequestSchema = z.object({
|
||||
place_time: z.string().nullable().optional(),
|
||||
end_time: z.string().nullable().optional(),
|
||||
});
|
||||
export type AssignmentTimeRequest = z.infer<typeof assignmentTimeRequestSchema>;
|
||||
|
||||
export const assignmentParticipantsRequestSchema = z.object({
|
||||
user_ids: z.array(z.number()),
|
||||
});
|
||||
export type AssignmentParticipantsRequest = z.infer<typeof assignmentParticipantsRequestSchema>;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
markRegionRequestSchema,
|
||||
createBucketItemRequestSchema,
|
||||
regionGeoSchema,
|
||||
} from './atlas.schema';
|
||||
|
||||
describe('markRegionRequestSchema', () => {
|
||||
it('requires both name and country_code', () => {
|
||||
expect(markRegionRequestSchema.safeParse({ name: 'Bavaria', country_code: 'DE' }).success).toBe(true);
|
||||
expect(markRegionRequestSchema.safeParse({ name: 'Bavaria' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBucketItemRequestSchema', () => {
|
||||
it('requires a name; coordinates and metadata optional/nullable', () => {
|
||||
expect(createBucketItemRequestSchema.safeParse({ name: 'Tokyo' }).success).toBe(true);
|
||||
expect(createBucketItemRequestSchema.safeParse({ name: 'Tokyo', lat: 35, lng: 139, country_code: null }).success).toBe(true);
|
||||
expect(createBucketItemRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regionGeoSchema', () => {
|
||||
it('accepts a FeatureCollection with opaque features', () => {
|
||||
expect(regionGeoSchema.safeParse({ type: 'FeatureCollection', features: [] }).success).toBe(true);
|
||||
expect(regionGeoSchema.safeParse({ type: 'FeatureCollection', features: [{ anything: true }] }).success).toBe(true);
|
||||
expect(regionGeoSchema.safeParse({ type: 'Other', features: [] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Atlas API contract — single source of truth for the /api/addons/atlas endpoints
|
||||
* (visited countries/regions, region GeoJSON, and the travel bucket list).
|
||||
*
|
||||
* Parity note: unlike the journey addon, the legacy atlas route is NOT gated by
|
||||
* an addon-enabled check (app.ts mounts it without one), so the migration does
|
||||
* not add a gate either — adding one would be a breaking 404.
|
||||
*
|
||||
* Stats, visited-regions and GeoJSON are wide, externally-derived shapes kept as
|
||||
* open records; the request schemas and the bespoke 400/404 controller messages
|
||||
* pin the parts the client depends on.
|
||||
*/
|
||||
|
||||
const open = z.record(z.string(), z.unknown());
|
||||
|
||||
export const markRegionRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
country_code: z.string().min(1),
|
||||
});
|
||||
export type MarkRegionRequest = z.infer<typeof markRegionRequestSchema>;
|
||||
|
||||
export const createBucketItemRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
lat: z.number().nullable().optional(),
|
||||
lng: z.number().nullable().optional(),
|
||||
country_code: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
target_date: z.string().nullable().optional(),
|
||||
});
|
||||
export type CreateBucketItemRequest = z.infer<typeof createBucketItemRequestSchema>;
|
||||
|
||||
export const updateBucketItemRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
lat: z.number().nullable().optional(),
|
||||
lng: z.number().nullable().optional(),
|
||||
country_code: z.string().nullable().optional(),
|
||||
target_date: z.string().nullable().optional(),
|
||||
});
|
||||
export type UpdateBucketItemRequest = z.infer<typeof updateBucketItemRequestSchema>;
|
||||
|
||||
/** A bucket-list item row (DB-shaped; kept open). */
|
||||
export const bucketItemSchema = open;
|
||||
|
||||
export const bucketListResponseSchema = z.object({
|
||||
items: z.array(bucketItemSchema),
|
||||
});
|
||||
export type BucketListResponse = z.infer<typeof bucketListResponseSchema>;
|
||||
|
||||
/** GeoJSON FeatureCollection (kept open — provider-derived geometry). */
|
||||
export const regionGeoSchema = z.object({
|
||||
type: z.literal('FeatureCollection'),
|
||||
features: z.array(z.unknown()),
|
||||
});
|
||||
export type RegionGeo = z.infer<typeof regionGeoSchema>;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
registerRequestSchema,
|
||||
loginRequestSchema,
|
||||
forgotPasswordRequestSchema,
|
||||
resetPasswordRequestSchema,
|
||||
changePasswordRequestSchema,
|
||||
mfaVerifyLoginRequestSchema,
|
||||
mfaEnableRequestSchema,
|
||||
mcpTokenCreateRequestSchema,
|
||||
} from './auth.schema';
|
||||
|
||||
describe('registerRequestSchema', () => {
|
||||
it('requires email + password; username/invite optional', () => {
|
||||
expect(registerRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' }).success).toBe(true);
|
||||
expect(registerRequestSchema.safeParse({ email: 'a@b.c', password: 'pw', invite_token: 't' }).success).toBe(true);
|
||||
expect(registerRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loginRequestSchema', () => {
|
||||
it('requires email + password', () => {
|
||||
expect(loginRequestSchema.safeParse({ email: 'a@b.c', password: 'pw' }).success).toBe(true);
|
||||
expect(loginRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forgot/reset/change password schemas', () => {
|
||||
it('validate their required fields', () => {
|
||||
expect(forgotPasswordRequestSchema.safeParse({ email: 'a@b.c' }).success).toBe(true);
|
||||
expect(resetPasswordRequestSchema.safeParse({ token: 't', password: 'pw' }).success).toBe(true);
|
||||
expect(resetPasswordRequestSchema.safeParse({ token: 't', password: 'pw', mfa_code: '123456' }).success).toBe(true);
|
||||
expect(resetPasswordRequestSchema.safeParse({ password: 'pw' }).success).toBe(false);
|
||||
expect(changePasswordRequestSchema.safeParse({ current_password: 'a', new_password: 'b' }).success).toBe(true);
|
||||
expect(changePasswordRequestSchema.safeParse({ new_password: 'b' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mfa + mcp-token schemas', () => {
|
||||
it('validate their fields', () => {
|
||||
expect(mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't', code: '123456' }).success).toBe(true);
|
||||
expect(mfaVerifyLoginRequestSchema.safeParse({ mfa_token: 't' }).success).toBe(false);
|
||||
expect(mfaEnableRequestSchema.safeParse({ code: '123456' }).success).toBe(true);
|
||||
expect(mcpTokenCreateRequestSchema.safeParse({ name: 'CLI' }).success).toBe(true);
|
||||
expect(mcpTokenCreateRequestSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Auth API contract for /api/auth.
|
||||
*
|
||||
* The auth service does the heavy credential/MFA validation internally (and
|
||||
* returns its own {error,status}); these schemas pin the well-defined request
|
||||
* bodies the public + account endpoints accept. Login/reset can branch to an
|
||||
* MFA step, so password fields stay permissive where the service owns the rules.
|
||||
*/
|
||||
export const registerRequestSchema = z.object({
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
username: z.string().optional(),
|
||||
invite_token: z.string().optional(),
|
||||
});
|
||||
export type RegisterRequest = z.infer<typeof registerRequestSchema>;
|
||||
|
||||
export const loginRequestSchema = z.object({
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
});
|
||||
export type LoginRequest = z.infer<typeof loginRequestSchema>;
|
||||
|
||||
export const forgotPasswordRequestSchema = z.object({
|
||||
email: z.string(),
|
||||
});
|
||||
export type ForgotPasswordRequest = z.infer<typeof forgotPasswordRequestSchema>;
|
||||
|
||||
export const resetPasswordRequestSchema = z.object({
|
||||
token: z.string(),
|
||||
password: z.string(),
|
||||
mfa_code: z.string().optional(),
|
||||
});
|
||||
export type ResetPasswordRequest = z.infer<typeof resetPasswordRequestSchema>;
|
||||
|
||||
export const changePasswordRequestSchema = z.object({
|
||||
current_password: z.string(),
|
||||
new_password: z.string(),
|
||||
});
|
||||
export type ChangePasswordRequest = z.infer<typeof changePasswordRequestSchema>;
|
||||
|
||||
export const mfaVerifyLoginRequestSchema = z.object({
|
||||
mfa_token: z.string(),
|
||||
code: z.string(),
|
||||
});
|
||||
export type MfaVerifyLoginRequest = z.infer<typeof mfaVerifyLoginRequestSchema>;
|
||||
|
||||
export const mfaEnableRequestSchema = z.object({
|
||||
code: z.string(),
|
||||
});
|
||||
export type MfaEnableRequest = z.infer<typeof mfaEnableRequestSchema>;
|
||||
|
||||
export const mcpTokenCreateRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
});
|
||||
export type McpTokenCreateRequest = z.infer<typeof mcpTokenCreateRequestSchema>;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { autoBackupSettingsRequestSchema } from './backup.schema';
|
||||
|
||||
describe('autoBackupSettingsRequestSchema', () => {
|
||||
it('accepts the known toggles and stays permissive for extras', () => {
|
||||
expect(autoBackupSettingsRequestSchema.safeParse({ enabled: true, interval: 'daily', keep_days: 7 }).success).toBe(true);
|
||||
expect(autoBackupSettingsRequestSchema.safeParse({ enabled: false, foo: 'bar' }).success).toBe(true);
|
||||
expect(autoBackupSettingsRequestSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a non-boolean enabled', () => {
|
||||
expect(autoBackupSettingsRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Backup API contract (admin-only) for /api/backup.
|
||||
*
|
||||
* The auto-backup settings body is normalised server-side by the backup
|
||||
* service (parseAutoBackupBody), so this schema only pins the well-known toggle
|
||||
* fields and stays permissive (passthrough) for the rest. Create/restore/delete
|
||||
* carry no JSON body; their inputs are the :filename path param + the upload.
|
||||
*/
|
||||
export const autoBackupSettingsRequestSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
interval: z.string().optional(),
|
||||
keep_days: z.union([z.string(), z.number()]).optional(),
|
||||
time: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export type AutoBackupSettingsRequest = z.infer<typeof autoBackupSettingsRequestSchema>;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
budgetCreateItemRequestSchema,
|
||||
budgetUpdateMembersRequestSchema,
|
||||
budgetToggleMemberPaidRequestSchema,
|
||||
budgetReorderItemsRequestSchema,
|
||||
} from './budget.schema';
|
||||
|
||||
describe('budgetCreateItemRequestSchema', () => {
|
||||
it('requires a name; money/meta fields optional + nullable', () => {
|
||||
expect(budgetCreateItemRequestSchema.safeParse({ name: 'Hotel' }).success).toBe(true);
|
||||
expect(budgetCreateItemRequestSchema.safeParse({ name: 'Hotel', total_price: 200, persons: null }).success).toBe(true);
|
||||
expect(budgetCreateItemRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('budgetUpdateMembersRequestSchema', () => {
|
||||
it('requires a numeric user_ids array', () => {
|
||||
expect(budgetUpdateMembersRequestSchema.safeParse({ user_ids: [1, 2] }).success).toBe(true);
|
||||
expect(budgetUpdateMembersRequestSchema.safeParse({ user_ids: 'no' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('budgetToggleMemberPaidRequestSchema', () => {
|
||||
it('requires a boolean paid', () => {
|
||||
expect(budgetToggleMemberPaidRequestSchema.safeParse({ paid: true }).success).toBe(true);
|
||||
expect(budgetToggleMemberPaidRequestSchema.safeParse({ paid: 'yes' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('budgetReorderItemsRequestSchema', () => {
|
||||
it('requires numeric ids', () => {
|
||||
expect(budgetReorderItemsRequestSchema.safeParse({ orderedIds: [3, 1, 2] }).success).toBe(true);
|
||||
expect(budgetReorderItemsRequestSchema.safeParse({ orderedIds: ['a'] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Budget API contract — single source of truth for the /api/trips/:tripId/budget
|
||||
* endpoints (expense items, per-member splits, paid toggles, settlement).
|
||||
*
|
||||
* Trip-scoped: every endpoint verifies trip access (404 "Trip not found") and
|
||||
* mutations check the 'budget_edit' permission (403 "No permission"). The legacy
|
||||
* route (server/src/routes/budget.ts) wraps services/budgetService.ts; rows are
|
||||
* DB-shaped and kept open. Mutations broadcast over WebSocket with the forwarded
|
||||
* X-Socket-Id. Updating a linked item's total_price also syncs the price into the
|
||||
* linked reservation's metadata (and broadcasts reservation:updated).
|
||||
*/
|
||||
|
||||
export const budgetCreateItemRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
category: z.string().optional(),
|
||||
total_price: z.number().optional(),
|
||||
persons: z.number().nullable().optional(),
|
||||
days: z.number().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
expense_date: z.string().nullable().optional(),
|
||||
});
|
||||
export type BudgetCreateItemRequest = z.infer<typeof budgetCreateItemRequestSchema>;
|
||||
|
||||
/** Update accepts the same fields plus total_price changes; all optional. */
|
||||
export const budgetUpdateItemRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
total_price: z.number().optional(),
|
||||
persons: z.number().nullable().optional(),
|
||||
days: z.number().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
expense_date: z.string().nullable().optional(),
|
||||
});
|
||||
export type BudgetUpdateItemRequest = z.infer<typeof budgetUpdateItemRequestSchema>;
|
||||
|
||||
export const budgetUpdateMembersRequestSchema = z.object({
|
||||
user_ids: z.array(z.number()),
|
||||
});
|
||||
export type BudgetUpdateMembersRequest = z.infer<typeof budgetUpdateMembersRequestSchema>;
|
||||
|
||||
export const budgetToggleMemberPaidRequestSchema = z.object({
|
||||
paid: z.boolean(),
|
||||
});
|
||||
export type BudgetToggleMemberPaidRequest = z.infer<typeof budgetToggleMemberPaidRequestSchema>;
|
||||
|
||||
export const budgetReorderItemsRequestSchema = z.object({
|
||||
orderedIds: z.array(z.number()),
|
||||
});
|
||||
export type BudgetReorderItemsRequest = z.infer<typeof budgetReorderItemsRequestSchema>;
|
||||
|
||||
export const budgetReorderCategoriesRequestSchema = z.object({
|
||||
orderedCategories: z.array(z.string()),
|
||||
});
|
||||
export type BudgetReorderCategoriesRequest = z.infer<typeof budgetReorderCategoriesRequestSchema>;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
categorySchema,
|
||||
createCategoryRequestSchema,
|
||||
updateCategoryRequestSchema,
|
||||
} from './category.schema';
|
||||
|
||||
describe('categorySchema', () => {
|
||||
it('accepts a full category', () => {
|
||||
expect(categorySchema.safeParse({ id: 1, name: 'Food', color: '#fff', icon: '🍔' }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCategoryRequestSchema', () => {
|
||||
it('requires a non-empty name; colour and icon are optional', () => {
|
||||
expect(createCategoryRequestSchema.safeParse({ name: 'Food' }).success).toBe(true);
|
||||
expect(createCategoryRequestSchema.safeParse({ name: '' }).success).toBe(false);
|
||||
expect(createCategoryRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCategoryRequestSchema', () => {
|
||||
it('allows every field to be omitted (the service COALESCEs)', () => {
|
||||
expect(updateCategoryRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(updateCategoryRequestSchema.safeParse({ color: '#000' }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Category API contract — single source of truth for the /api/categories endpoints.
|
||||
*
|
||||
* Categories are the place-category palette (also the admin "Personalization"
|
||||
* surface). Reading is open to any authenticated user; create/update/delete are
|
||||
* admin-only. The legacy route (server/src/routes/categories.ts) wraps
|
||||
* services/categoryService.ts 1:1.
|
||||
*
|
||||
* The bespoke 400 ("Category name is required") and 404 ("Category not found")
|
||||
* messages are reproduced in the controller so the bodies stay byte-identical.
|
||||
*/
|
||||
|
||||
export const categorySchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
color: z.string(),
|
||||
icon: z.string(),
|
||||
user_id: z.number().nullable().optional(),
|
||||
created_at: z.string().optional(),
|
||||
});
|
||||
export type Category = z.infer<typeof categorySchema>;
|
||||
|
||||
export const createCategoryRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
color: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
});
|
||||
export type CreateCategoryRequest = z.infer<typeof createCategoryRequestSchema>;
|
||||
|
||||
/** All fields optional — the service COALESCEs each against the stored value. */
|
||||
export const updateCategoryRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
});
|
||||
export type UpdateCategoryRequest = z.infer<typeof updateCategoryRequestSchema>;
|
||||
|
||||
export const categoryListResponseSchema = z.object({
|
||||
categories: z.array(categorySchema),
|
||||
});
|
||||
export type CategoryListResponse = z.infer<typeof categoryListResponseSchema>;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
collabNoteCreateRequestSchema,
|
||||
collabPollCreateRequestSchema,
|
||||
collabPollVoteRequestSchema,
|
||||
collabMessageCreateRequestSchema,
|
||||
collabReactionRequestSchema,
|
||||
} from './collab.schema';
|
||||
|
||||
describe('collabNoteCreateRequestSchema', () => {
|
||||
it('requires a non-empty title; the rest is optional', () => {
|
||||
expect(collabNoteCreateRequestSchema.safeParse({ title: 'Idea' }).success).toBe(true);
|
||||
expect(collabNoteCreateRequestSchema.safeParse({ title: '' }).success).toBe(false);
|
||||
expect(collabNoteCreateRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collabPollCreateRequestSchema', () => {
|
||||
it('requires a question and at least two options', () => {
|
||||
expect(collabPollCreateRequestSchema.safeParse({ question: 'Where?', options: ['A', 'B'] }).success).toBe(true);
|
||||
expect(collabPollCreateRequestSchema.safeParse({ question: 'Where?', options: ['A'] }).success).toBe(false);
|
||||
expect(collabPollCreateRequestSchema.safeParse({ options: ['A', 'B'] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collabPollVoteRequestSchema', () => {
|
||||
it('requires a numeric option_index', () => {
|
||||
expect(collabPollVoteRequestSchema.safeParse({ option_index: 0 }).success).toBe(true);
|
||||
expect(collabPollVoteRequestSchema.safeParse({ option_index: 'a' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collabMessageCreateRequestSchema', () => {
|
||||
it('requires text, caps it at 5000, allows a nullable reply_to', () => {
|
||||
expect(collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: null }).success).toBe(true);
|
||||
expect(collabMessageCreateRequestSchema.safeParse({ text: 'hi', reply_to: 4 }).success).toBe(true);
|
||||
expect(collabMessageCreateRequestSchema.safeParse({ text: '' }).success).toBe(false);
|
||||
expect(collabMessageCreateRequestSchema.safeParse({ text: 'x'.repeat(5001) }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collabReactionRequestSchema', () => {
|
||||
it('requires a non-empty emoji', () => {
|
||||
expect(collabReactionRequestSchema.safeParse({ emoji: '👍' }).success).toBe(true);
|
||||
expect(collabReactionRequestSchema.safeParse({ emoji: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Collab API contract — single source of truth for the /api/trips/:tripId/collab
|
||||
* endpoints (shared notes + file attachments, decision polls, group chat with
|
||||
* reactions, link previews).
|
||||
*
|
||||
* Trip-scoped; mutations use 'collab_edit' (file uploads use 'file_upload'). The
|
||||
* legacy route (server/src/routes/collab.ts) wraps collabService and broadcasts
|
||||
* over WebSocket + fires chat/note notifications. Rows are wide and kept open;
|
||||
* the request schemas + the bespoke 400/403/404 controller messages pin the rest.
|
||||
*/
|
||||
|
||||
export const collabNoteCreateRequestSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
content: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
});
|
||||
export type CollabNoteCreateRequest = z.infer<typeof collabNoteCreateRequestSchema>;
|
||||
|
||||
export const collabNoteUpdateRequestSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
content: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
pinned: z.union([z.boolean(), z.number()]).optional(),
|
||||
website: z.string().optional(),
|
||||
});
|
||||
export type CollabNoteUpdateRequest = z.infer<typeof collabNoteUpdateRequestSchema>;
|
||||
|
||||
export const collabPollCreateRequestSchema = z.object({
|
||||
question: z.string().min(1),
|
||||
options: z.array(z.unknown()).min(2),
|
||||
multiple: z.boolean().optional(),
|
||||
multiple_choice: z.boolean().optional(),
|
||||
deadline: z.string().optional(),
|
||||
});
|
||||
export type CollabPollCreateRequest = z.infer<typeof collabPollCreateRequestSchema>;
|
||||
|
||||
export const collabPollVoteRequestSchema = z.object({
|
||||
option_index: z.number(),
|
||||
});
|
||||
export type CollabPollVoteRequest = z.infer<typeof collabPollVoteRequestSchema>;
|
||||
|
||||
export const collabMessageCreateRequestSchema = z.object({
|
||||
text: z.string().min(1).max(5000),
|
||||
reply_to: z.number().nullable().optional(),
|
||||
});
|
||||
export type CollabMessageCreateRequest = z.infer<typeof collabMessageCreateRequestSchema>;
|
||||
|
||||
export const collabReactionRequestSchema = z.object({
|
||||
emoji: z.string().min(1),
|
||||
});
|
||||
export type CollabReactionRequest = z.infer<typeof collabReactionRequestSchema>;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Public config contract — the unauthenticated /api/config endpoint.
|
||||
*
|
||||
* This is the only public (non-authenticated) endpoint in the L2 bundle: the
|
||||
* login page reads it before a user signs in to pick the initial language. The
|
||||
* legacy route (server/src/routes/publicConfig.ts) returns just the server's
|
||||
* configured default language, so the response is intentionally minimal.
|
||||
*/
|
||||
export const publicConfigSchema = z.object({
|
||||
defaultLanguage: z.string(),
|
||||
});
|
||||
export type PublicConfig = z.infer<typeof publicConfigSchema>;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
dayCreateRequestSchema,
|
||||
dayNoteCreateRequestSchema,
|
||||
dayNoteUpdateRequestSchema,
|
||||
} from './day.schema';
|
||||
|
||||
describe('dayCreateRequestSchema', () => {
|
||||
it('accepts an optional date + notes', () => {
|
||||
expect(dayCreateRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(dayCreateRequestSchema.safeParse({ date: '2026-07-01', notes: 'n' }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayNoteCreateRequestSchema', () => {
|
||||
it('requires non-empty text capped at 500, time capped at 150', () => {
|
||||
expect(dayNoteCreateRequestSchema.safeParse({ text: 'Lunch' }).success).toBe(true);
|
||||
expect(dayNoteCreateRequestSchema.safeParse({ text: '' }).success).toBe(false);
|
||||
expect(dayNoteCreateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false);
|
||||
expect(dayNoteCreateRequestSchema.safeParse({ text: 'ok', time: 'y'.repeat(151) }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayNoteUpdateRequestSchema', () => {
|
||||
it('allows omitting text and caps the lengths', () => {
|
||||
expect(dayNoteUpdateRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(dayNoteUpdateRequestSchema.safeParse({ icon: '🍽️' }).success).toBe(true);
|
||||
expect(dayNoteUpdateRequestSchema.safeParse({ text: 'x'.repeat(501) }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Day + day-note API contract — single source of truth for the
|
||||
* /api/trips/:tripId/days and /api/trips/:tripId/days/:dayId/notes endpoints.
|
||||
*
|
||||
* Trip-scoped, both gated by the 'day_edit' permission. The legacy routes
|
||||
* (server/src/routes/days.ts + routes/dayNotes.ts) wrap dayService /
|
||||
* dayNoteService. Day rows (with their assignments) are wide and DB-derived, so
|
||||
* list responses stay open. Day notes cap text at 500 and time at 150 chars
|
||||
* (the legacy validateStringLengths middleware) — reproduced in the controller.
|
||||
*/
|
||||
|
||||
export const dayCreateRequestSchema = z.object({
|
||||
date: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type DayCreateRequest = z.infer<typeof dayCreateRequestSchema>;
|
||||
|
||||
export const dayUpdateRequestSchema = z.object({
|
||||
notes: z.string().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
});
|
||||
export type DayUpdateRequest = z.infer<typeof dayUpdateRequestSchema>;
|
||||
|
||||
export const dayNoteCreateRequestSchema = z.object({
|
||||
text: z.string().min(1).max(500),
|
||||
time: z.string().max(150).optional(),
|
||||
icon: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
});
|
||||
export type DayNoteCreateRequest = z.infer<typeof dayNoteCreateRequestSchema>;
|
||||
|
||||
export const dayNoteUpdateRequestSchema = z.object({
|
||||
text: z.string().max(500).optional(),
|
||||
time: z.string().max(150).optional(),
|
||||
icon: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
});
|
||||
export type DayNoteUpdateRequest = z.infer<typeof dayNoteUpdateRequestSchema>;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fileUpdateRequestSchema, fileLinkRequestSchema, photoVariantSchema } from './file.schema';
|
||||
|
||||
describe('fileUpdateRequestSchema', () => {
|
||||
it('accepts optional metadata, nullable ids, an empty body', () => {
|
||||
expect(fileUpdateRequestSchema.safeParse({ description: 'doc', place_id: 3 }).success).toBe(true);
|
||||
expect(fileUpdateRequestSchema.safeParse({ place_id: null, reservation_id: '7' }).success).toBe(true);
|
||||
expect(fileUpdateRequestSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileLinkRequestSchema', () => {
|
||||
it('accepts any subset of reservation/assignment/place ids', () => {
|
||||
expect(fileLinkRequestSchema.safeParse({ reservation_id: 1 }).success).toBe(true);
|
||||
expect(fileLinkRequestSchema.safeParse({ assignment_id: '2', place_id: null }).success).toBe(true);
|
||||
expect(fileLinkRequestSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('photoVariantSchema', () => {
|
||||
it('only allows thumbnail or original', () => {
|
||||
expect(photoVariantSchema.safeParse('thumbnail').success).toBe(true);
|
||||
expect(photoVariantSchema.safeParse('original').success).toBe(true);
|
||||
expect(photoVariantSchema.safeParse('full').success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* File + photo API contract.
|
||||
*
|
||||
* Files live under /api/trips/:tripId/files (upload, metadata, star, trash,
|
||||
* reservation links, authenticated download). Photos live under /api/photos
|
||||
* (thumbnail/original streaming + info) and are global, not trip-scoped.
|
||||
*
|
||||
* Uploads are multipart/form-data so the file itself isn't modelled here; these
|
||||
* schemas pin the JSON-ish metadata fields that ride along or come as request
|
||||
* bodies. The bespoke 400/403/404 controller messages pin the rest.
|
||||
*/
|
||||
|
||||
const nullableIdField = z.union([z.string(), z.number()]).nullable().optional();
|
||||
|
||||
export const fileUpdateRequestSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
place_id: nullableIdField,
|
||||
reservation_id: nullableIdField,
|
||||
});
|
||||
export type FileUpdateRequest = z.infer<typeof fileUpdateRequestSchema>;
|
||||
|
||||
export const fileLinkRequestSchema = z.object({
|
||||
reservation_id: nullableIdField,
|
||||
assignment_id: nullableIdField,
|
||||
place_id: nullableIdField,
|
||||
});
|
||||
export type FileLinkRequest = z.infer<typeof fileLinkRequestSchema>;
|
||||
|
||||
/** Variants the photo streaming endpoints accept. */
|
||||
export const photoVariantSchema = z.enum(['thumbnail', 'original']);
|
||||
export type PhotoVariant = z.infer<typeof photoVariantSchema>;
|
||||
@@ -5,6 +5,7 @@ import de from '../de/externalNotifications';
|
||||
import en from '../en/externalNotifications';
|
||||
import es from '../es/externalNotifications';
|
||||
import fr from '../fr/externalNotifications';
|
||||
import gr from '../gr/externalNotifications';
|
||||
import hu from '../hu/externalNotifications';
|
||||
import id from '../id/externalNotifications';
|
||||
import it from '../it/externalNotifications';
|
||||
@@ -47,6 +48,7 @@ const LOCALES = {
|
||||
ja,
|
||||
ko,
|
||||
uk,
|
||||
gr,
|
||||
} satisfies Record<string, NotificationLocale>;
|
||||
|
||||
export const EMAIL_I18N: Record<string, EmailStrings> = Object.fromEntries(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { NotificationLocale } from '../externalNotifications/types';
|
||||
|
||||
const gr: NotificationLocale = {
|
||||
email: {
|
||||
footer:
|
||||
'Λάβατε αυτό το μήνυμα επειδή έχετε ενεργοποιήσει τις ειδοποιήσεις στο TREK.',
|
||||
manage: 'Διαχείριση προτιμήσεων στις Ρυθμίσεις',
|
||||
madeWith: 'Δημιουργήθηκε με',
|
||||
openTrek: 'Άνοιγμα TREK',
|
||||
},
|
||||
events: {
|
||||
trip_invite: (p) => ({
|
||||
title: `Πρόσκληση ταξιδιού: "${p.trip}"`,
|
||||
body: `Ο/Η ${p.actor} προσκάλεσε ${p.invitee || 'ένα μέλος'} στο ταξίδι "${p.trip}".`,
|
||||
}),
|
||||
booking_change: (p) => ({
|
||||
title: `Νέα κράτηση: ${p.booking}`,
|
||||
body: `Ο/Η ${p.actor} πρόσθεσε μια νέα κράτηση "${p.booking}" (${p.type}) στο "${p.trip}".`,
|
||||
}),
|
||||
trip_reminder: (p) => ({
|
||||
title: `Υπενθύμιση ταξιδιού: ${p.trip}`,
|
||||
body: `Το ταξίδι σας "${p.trip}" πλησιάζει!`,
|
||||
}),
|
||||
todo_due: (p) => ({
|
||||
title: `Εκκρεμότητα προς εκτέλεση: ${p.todo}`,
|
||||
body: `Η εκκρεμότητα "${p.todo}" στο "${p.trip}" λήγει στις ${p.due}.`,
|
||||
}),
|
||||
vacay_invite: (p) => ({
|
||||
title: 'Πρόσκληση συγχώνευσης διακοπών',
|
||||
body: `Ο/Η ${p.actor} σας προσκάλεσε να συγχωνεύσετε τα σχέδια διακοπών σας. Ανοίξτε το TREK για να αποδεχτείτε ή να απορρίψετε.`,
|
||||
}),
|
||||
photos_shared: (p) => ({
|
||||
title: `${p.count} φωτογραφίες κοινοποιήθηκαν`,
|
||||
body: `Ο/Η ${p.actor} κοινοποίησε ${p.count} φωτογραφία/ες στο "${p.trip}".`,
|
||||
}),
|
||||
collab_message: (p) => ({
|
||||
title: `Νέο μήνυμα στο "${p.trip}"`,
|
||||
body: `${p.actor}: ${p.preview}`,
|
||||
}),
|
||||
packing_tagged: (p) => ({
|
||||
title: `Λίστα συσκευασίας: ${p.category}`,
|
||||
body: `Ο/Η ${p.actor} σας ανέθεσε στην κατηγορία "${p.category}" της λίστας συσκευασίας στο "${p.trip}".`,
|
||||
}),
|
||||
version_available: (p) => ({
|
||||
title: 'Νέα έκδοση TREK διαθέσιμη',
|
||||
body: `Η έκδοση TREK ${p.version} είναι τώρα διαθέσιμη. Επισκεφθείτε τον πίνακα διαχείρισης για να ενημερώσετε.`,
|
||||
}),
|
||||
synology_session_cleared: () => ({
|
||||
title: 'Η σύνδεση Synology τερματίστηκε',
|
||||
body: 'Ο λογαριασμός σας Synology ή το URL άλλαξε. Έχετε αποσυνδεθεί από το Synology Photos.',
|
||||
}),
|
||||
},
|
||||
passwordReset: {
|
||||
subject: 'Επαναφορά κωδικού πρόσβασης',
|
||||
greeting: 'Γεια σας',
|
||||
body: 'Λάβαμε ένα αίτημα επαναφοράς του κωδικού πρόσβασης για τον λογαριασμό σας στο TREK. Κάντε κλικ στο παρακάτω κουμπί για να ορίσετε νέο κωδικό πρόσβασης.',
|
||||
ctaIntro: 'Επαναφορά κωδικού',
|
||||
expiry: 'Αυτός ο σύνδεσμος λήγει σε 60 λεπτά.',
|
||||
ignore:
|
||||
'Εάν δεν ζητήσατε αυτή την αλλαγή, μπορείτε να αγνοήσετε αυτό το μήνυμα — ο κωδικός σας δεν θα αλλάξει.',
|
||||
},
|
||||
};
|
||||
|
||||
export default gr;
|
||||
@@ -62,7 +62,6 @@ const settings: TranslationStrings = {
|
||||
'settings.language': 'Γλώσσα',
|
||||
'settings.temperature': 'Μονάδα Θερμοκρασίας',
|
||||
'settings.timeFormat': 'Μορφή Ώρας',
|
||||
'settings.routeCalculation': 'Υπολογισμός Διαδρομής',
|
||||
'settings.bookingLabels': 'Ετικέτες διαδρομής κρατήσεων',
|
||||
'settings.bookingLabelsHint':
|
||||
'Εμφάνιση ονομάτων σταθμών / αεροδρομίων στον χάρτη. Όταν είναι απενεργοποιημένο, εμφανίζεται μόνο το εικονίδιο.',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// @ts-expect-error — plain .mjs script with no .d.ts; import as JS module.
|
||||
import { checkParity } from '../../scripts/i18n-parity.mjs';
|
||||
|
||||
/**
|
||||
* Enforces the file-set contract for the i18n migration: every non-en locale
|
||||
* dir must contain the exact same domain files as en/.
|
||||
*
|
||||
* Key-set drift is intentionally NOT enforced here — translation work happens
|
||||
* gradually and gating CI on every newly-added EN key would block feature
|
||||
* merges. The CLI script still prints the key-drift report so translators can
|
||||
* see what they owe; only file-level drift is a structural bug.
|
||||
*/
|
||||
describe('i18n parity', () => {
|
||||
it('every locale has the same domain files as en', () => {
|
||||
const report = checkParity();
|
||||
expect(report.fileDrift).toEqual([]);
|
||||
});
|
||||
|
||||
it('reports key drift as data (not enforced, used by the CLI tool)', () => {
|
||||
const report = checkParity();
|
||||
// We do not assert here — translation drift is expected and acceptable.
|
||||
// The shape check just confirms the report contract for tooling consumers.
|
||||
expect(Array.isArray(report.keyDrift)).toBe(true);
|
||||
for (const entry of report.keyDrift) {
|
||||
expect(typeof entry.locale).toBe('string');
|
||||
expect(typeof entry.file).toBe('string');
|
||||
expect(Array.isArray(entry.missing)).toBe(true);
|
||||
expect(Array.isArray(entry.extra)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,37 @@ export * from './common/pagination.schema';
|
||||
|
||||
// Domain contracts
|
||||
export * from './weather/weather.schema';
|
||||
export * from './airport/airport.schema';
|
||||
export * from './config/config.schema';
|
||||
export * from './system-notice/system-notice.schema';
|
||||
export * from './maps/maps.schema';
|
||||
export * from './category/category.schema';
|
||||
export * from './tag/tag.schema';
|
||||
export * from './notification/notification.schema';
|
||||
export * from './atlas/atlas.schema';
|
||||
export * from './vacay/vacay.schema';
|
||||
export * from './packing/packing.schema';
|
||||
export * from './todo/todo.schema';
|
||||
export * from './budget/budget.schema';
|
||||
export * from './reservation/reservation.schema';
|
||||
export * from './day/day.schema';
|
||||
export * from './assignment/assignment.schema';
|
||||
export * from './place/place.schema';
|
||||
export * from './trip/trip.schema';
|
||||
export * from './collab/collab.schema';
|
||||
export * from './file/file.schema';
|
||||
export * from './journey/journey.schema';
|
||||
export * from './share/share.schema';
|
||||
export * from './settings/settings.schema';
|
||||
export * from './backup/backup.schema';
|
||||
export * from './auth/auth.schema';
|
||||
export * from './oidc/oidc.schema';
|
||||
export * from './oauth/oauth.schema';
|
||||
export * from './admin/admin.schema';
|
||||
|
||||
// Sanitisation helpers — used by the client today, scoped here so the server
|
||||
// has them ready if rich-text input ever ships.
|
||||
export * from './sanitize/sanitize';
|
||||
|
||||
// i18n registry (language list + pure helpers — no locale data)
|
||||
export * from './i18n/languages';
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
journeyCreateRequestSchema,
|
||||
journeyAddTripRequestSchema,
|
||||
journeyReorderEntriesRequestSchema,
|
||||
journeyContributorRequestSchema,
|
||||
journeyProviderPhotosRequestSchema,
|
||||
journeyShareLinkRequestSchema,
|
||||
} from './journey.schema';
|
||||
|
||||
describe('journeyCreateRequestSchema', () => {
|
||||
it('requires a title; subtitle + trip_ids optional', () => {
|
||||
expect(journeyCreateRequestSchema.safeParse({ title: 'Trip of a lifetime' }).success).toBe(true);
|
||||
expect(journeyCreateRequestSchema.safeParse({ title: 'X', trip_ids: [1, '2'] }).success).toBe(true);
|
||||
expect(journeyCreateRequestSchema.safeParse({ subtitle: 'no title' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('journeyAddTripRequestSchema', () => {
|
||||
it('requires a trip_id (string or number)', () => {
|
||||
expect(journeyAddTripRequestSchema.safeParse({ trip_id: 5 }).success).toBe(true);
|
||||
expect(journeyAddTripRequestSchema.safeParse({ trip_id: '5' }).success).toBe(true);
|
||||
expect(journeyAddTripRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('journeyReorderEntriesRequestSchema', () => {
|
||||
it('requires a non-empty orderedIds array', () => {
|
||||
expect(journeyReorderEntriesRequestSchema.safeParse({ orderedIds: [3, 1, 2] }).success).toBe(true);
|
||||
expect(journeyReorderEntriesRequestSchema.safeParse({ orderedIds: [] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('journeyContributorRequestSchema', () => {
|
||||
it('requires user_id; role limited to editor/viewer', () => {
|
||||
expect(journeyContributorRequestSchema.safeParse({ user_id: 2 }).success).toBe(true);
|
||||
expect(journeyContributorRequestSchema.safeParse({ user_id: 2, role: 'editor' }).success).toBe(true);
|
||||
expect(journeyContributorRequestSchema.safeParse({ user_id: 2, role: 'admin' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('journeyProviderPhotosRequestSchema', () => {
|
||||
it('requires a provider; accepts single asset_id or a batch', () => {
|
||||
expect(journeyProviderPhotosRequestSchema.safeParse({ provider: 'immich', asset_id: 'a1' }).success).toBe(true);
|
||||
expect(journeyProviderPhotosRequestSchema.safeParse({ provider: 'immich', asset_ids: ['a1', 'a2'] }).success).toBe(true);
|
||||
expect(journeyProviderPhotosRequestSchema.safeParse({ asset_id: 'a1' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('journeyShareLinkRequestSchema', () => {
|
||||
it('accepts optional share toggles', () => {
|
||||
expect(journeyShareLinkRequestSchema.safeParse({ share_timeline: true, share_gallery: false }).success).toBe(true);
|
||||
expect(journeyShareLinkRequestSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Journey API contract — cross-trip travel narrative (journeys, dated entries,
|
||||
* a photo gallery with provider mirroring, contributors, per-user preferences
|
||||
* and public share links).
|
||||
*
|
||||
* Authenticated routes live under /api/journeys (gated by the Journey addon);
|
||||
* the public read/photo-proxy routes live under /api/public/journey and are
|
||||
* share-token validated. Access control lives inside journeyService (it returns
|
||||
* null/false → the controller maps to 403/404), so these schemas pin the
|
||||
* well-defined request bodies; entry create/update stay open-ended (forwarded
|
||||
* to the service) and the bespoke 400/403/404 messages pin the rest.
|
||||
*/
|
||||
|
||||
export const journeyCreateRequestSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
subtitle: z.string().optional(),
|
||||
trip_ids: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
});
|
||||
export type JourneyCreateRequest = z.infer<typeof journeyCreateRequestSchema>;
|
||||
|
||||
export const journeyAddTripRequestSchema = z.object({
|
||||
trip_id: z.union([z.string(), z.number()]),
|
||||
});
|
||||
export type JourneyAddTripRequest = z.infer<typeof journeyAddTripRequestSchema>;
|
||||
|
||||
export const journeyReorderEntriesRequestSchema = z.object({
|
||||
orderedIds: z.array(z.union([z.string(), z.number()])).min(1),
|
||||
});
|
||||
export type JourneyReorderEntriesRequest = z.infer<typeof journeyReorderEntriesRequestSchema>;
|
||||
|
||||
export const journeyContributorRequestSchema = z.object({
|
||||
user_id: z.union([z.string(), z.number()]),
|
||||
role: z.enum(['editor', 'viewer']).optional(),
|
||||
});
|
||||
export type JourneyContributorRequest = z.infer<typeof journeyContributorRequestSchema>;
|
||||
|
||||
export const journeyProviderPhotosRequestSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
asset_id: z.string().optional(),
|
||||
asset_ids: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
caption: z.string().optional(),
|
||||
passphrase: z.string().optional(),
|
||||
});
|
||||
export type JourneyProviderPhotosRequest = z.infer<typeof journeyProviderPhotosRequestSchema>;
|
||||
|
||||
export const journeyShareLinkRequestSchema = z.object({
|
||||
share_timeline: z.boolean().optional(),
|
||||
share_gallery: z.boolean().optional(),
|
||||
share_map: z.boolean().optional(),
|
||||
});
|
||||
export type JourneyShareLinkRequest = z.infer<typeof journeyShareLinkRequestSchema>;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
mapsSearchRequestSchema,
|
||||
mapsAutocompleteRequestSchema,
|
||||
mapsReverseQuerySchema,
|
||||
mapsResolveUrlRequestSchema,
|
||||
} from './maps.schema';
|
||||
|
||||
describe('mapsSearchRequestSchema', () => {
|
||||
it('requires a non-empty query', () => {
|
||||
expect(mapsSearchRequestSchema.safeParse({ query: 'berlin' }).success).toBe(true);
|
||||
expect(mapsSearchRequestSchema.safeParse({ query: '' }).success).toBe(false);
|
||||
expect(mapsSearchRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapsAutocompleteRequestSchema', () => {
|
||||
it('caps input at 200 chars and allows an optional locationBias', () => {
|
||||
expect(mapsAutocompleteRequestSchema.safeParse({ input: 'be' }).success).toBe(true);
|
||||
expect(mapsAutocompleteRequestSchema.safeParse({ input: 'x'.repeat(201) }).success).toBe(false);
|
||||
expect(mapsAutocompleteRequestSchema.safeParse({
|
||||
input: 'be', locationBias: { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } },
|
||||
}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapsReverseQuerySchema', () => {
|
||||
it('requires lat and lng as strings (the route parses them downstream)', () => {
|
||||
expect(mapsReverseQuerySchema.safeParse({ lat: '52.5', lng: '13.4' }).success).toBe(true);
|
||||
expect(mapsReverseQuerySchema.safeParse({ lat: '52.5' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapsResolveUrlRequestSchema', () => {
|
||||
it('requires a non-empty url', () => {
|
||||
expect(mapsResolveUrlRequestSchema.safeParse({ url: 'https://maps.app.goo.gl/x' }).success).toBe(true);
|
||||
expect(mapsResolveUrlRequestSchema.safeParse({ url: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Maps / geo API contract — single source of truth for the /api/maps endpoints.
|
||||
*
|
||||
* The legacy Express route (server/src/routes/maps.ts) is a thin layer over
|
||||
* services/mapsService.ts, which talks to Nominatim/Overpass (and optionally
|
||||
* Google Places when a key is configured) and applies the SSRF guard on every
|
||||
* outbound URL. The place objects these return are provider-shaped and vary by
|
||||
* source, so the response schemas keep them as open records — the contract pins
|
||||
* down the request shapes and the stable envelope fields, not the provider blobs.
|
||||
*
|
||||
* The bespoke 400 validation messages and the per-endpoint kill-switch responses
|
||||
* are reproduced in the controller, not derived from these schemas, so the bodies
|
||||
* stay byte-identical to Express.
|
||||
*/
|
||||
|
||||
const latLng = z.object({ lat: z.number(), lng: z.number() });
|
||||
|
||||
export const mapsSearchRequestSchema = z.object({
|
||||
query: z.string().min(1),
|
||||
});
|
||||
export type MapsSearchRequest = z.infer<typeof mapsSearchRequestSchema>;
|
||||
|
||||
export const mapsAutocompleteRequestSchema = z.object({
|
||||
input: z.string().min(1).max(200),
|
||||
lang: z.string().optional(),
|
||||
locationBias: z.object({ low: latLng, high: latLng }).optional(),
|
||||
});
|
||||
export type MapsAutocompleteRequest = z.infer<typeof mapsAutocompleteRequestSchema>;
|
||||
|
||||
export const mapsReverseQuerySchema = z.object({
|
||||
lat: z.string().min(1),
|
||||
lng: z.string().min(1),
|
||||
lang: z.string().optional(),
|
||||
});
|
||||
export type MapsReverseQuery = z.infer<typeof mapsReverseQuerySchema>;
|
||||
|
||||
export const mapsResolveUrlRequestSchema = z.object({
|
||||
url: z.string().min(1),
|
||||
});
|
||||
export type MapsResolveUrlRequest = z.infer<typeof mapsResolveUrlRequestSchema>;
|
||||
|
||||
/** Provider-shaped place blob (Google/OSM fields differ); kept open by design. */
|
||||
const placeRecord = z.record(z.string(), z.unknown());
|
||||
|
||||
export const mapsSearchResultSchema = z.object({
|
||||
places: z.array(placeRecord),
|
||||
source: z.string(),
|
||||
});
|
||||
export type MapsSearchResult = z.infer<typeof mapsSearchResultSchema>;
|
||||
|
||||
export const mapsAutocompleteSuggestionSchema = z.object({
|
||||
placeId: z.string(),
|
||||
mainText: z.string(),
|
||||
secondaryText: z.string(),
|
||||
});
|
||||
export const mapsAutocompleteResultSchema = z.object({
|
||||
suggestions: z.array(mapsAutocompleteSuggestionSchema),
|
||||
source: z.string(),
|
||||
});
|
||||
export type MapsAutocompleteResult = z.infer<typeof mapsAutocompleteResultSchema>;
|
||||
|
||||
export const mapsPlaceDetailsResultSchema = z.object({
|
||||
place: placeRecord.nullable(),
|
||||
disabled: z.boolean().optional(),
|
||||
});
|
||||
export type MapsPlaceDetailsResult = z.infer<typeof mapsPlaceDetailsResultSchema>;
|
||||
|
||||
export const mapsPlacePhotoResultSchema = z.object({
|
||||
photoUrl: z.string().nullable(),
|
||||
attribution: z.string().nullable().optional(),
|
||||
});
|
||||
export type MapsPlacePhotoResult = z.infer<typeof mapsPlacePhotoResultSchema>;
|
||||
|
||||
export const mapsReverseResultSchema = z.object({
|
||||
name: z.string().nullable(),
|
||||
address: z.string().nullable(),
|
||||
});
|
||||
export type MapsReverseResult = z.infer<typeof mapsReverseResultSchema>;
|
||||
|
||||
export const mapsResolveUrlResultSchema = z.object({
|
||||
lat: z.number(),
|
||||
lng: z.number(),
|
||||
name: z.string().nullable(),
|
||||
address: z.string().nullable(),
|
||||
});
|
||||
export type MapsResolveUrlResult = z.infer<typeof mapsResolveUrlResultSchema>;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
preferencesUpdateRequestSchema,
|
||||
notificationRespondRequestSchema,
|
||||
channelTestResultSchema,
|
||||
inAppListResultSchema,
|
||||
} from './notification.schema';
|
||||
|
||||
describe('preferencesUpdateRequestSchema', () => {
|
||||
it('accepts a nested event/channel/enabled matrix', () => {
|
||||
expect(preferencesUpdateRequestSchema.safeParse({ trip_invite: { inapp: true, email: false } }).success).toBe(true);
|
||||
expect(preferencesUpdateRequestSchema.safeParse({ trip_invite: { inapp: 'yes' } }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notificationRespondRequestSchema', () => {
|
||||
it('only accepts positive/negative', () => {
|
||||
expect(notificationRespondRequestSchema.safeParse({ response: 'positive' }).success).toBe(true);
|
||||
expect(notificationRespondRequestSchema.safeParse({ response: 'maybe' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('channelTestResultSchema', () => {
|
||||
it('accepts a success result and an error result', () => {
|
||||
expect(channelTestResultSchema.safeParse({ success: true }).success).toBe(true);
|
||||
expect(channelTestResultSchema.safeParse({ success: false, error: 'SMTP down' }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inAppListResultSchema', () => {
|
||||
it('accepts the list envelope with open notification rows', () => {
|
||||
expect(inAppListResultSchema.safeParse({
|
||||
notifications: [{ id: 1, type: 'info', anything: 'goes' }], total: 1, unread_count: 0,
|
||||
}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Notification API contract — single source of truth for the /api/notifications
|
||||
* endpoints (channel-preference matrix, channel test pings, and in-app
|
||||
* notifications).
|
||||
*
|
||||
* The notification row and the preferences matrix are wide, DB- and
|
||||
* registry-derived shapes; the response schemas keep them as open records and
|
||||
* pin the stable envelope fields, while the request schemas and the bespoke
|
||||
* 400/403/404 controller messages capture the parts the client depends on.
|
||||
* Real-time delivery happens over the existing WebSocket path inside the
|
||||
* services and is untouched by this contract.
|
||||
*/
|
||||
|
||||
/** Channel preference matrix update: { eventType: { channel: enabled } }. */
|
||||
export const preferencesUpdateRequestSchema = z.record(
|
||||
z.string(),
|
||||
z.record(z.string(), z.boolean()),
|
||||
);
|
||||
export type PreferencesUpdateRequest = z.infer<typeof preferencesUpdateRequestSchema>;
|
||||
|
||||
export const testSmtpRequestSchema = z.object({ email: z.string().optional() });
|
||||
export const testWebhookRequestSchema = z.object({ url: z.string().optional() });
|
||||
export const testNtfyRequestSchema = z.object({
|
||||
topic: z.string().optional(),
|
||||
server: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Result of a channel test ping. */
|
||||
export const channelTestResultSchema = z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
export type ChannelTestResult = z.infer<typeof channelTestResultSchema>;
|
||||
|
||||
/** Respond to a boolean (yes/no) notification. */
|
||||
export const notificationRespondRequestSchema = z.object({
|
||||
response: z.enum(['positive', 'negative']),
|
||||
});
|
||||
export type NotificationRespondRequest = z.infer<typeof notificationRespondRequestSchema>;
|
||||
|
||||
/** A single in-app notification row (DB-shaped; kept open). */
|
||||
export const notificationRowSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
export const inAppListResultSchema = z.object({
|
||||
notifications: z.array(notificationRowSchema),
|
||||
total: z.number(),
|
||||
unread_count: z.number(),
|
||||
});
|
||||
export type InAppListResult = z.infer<typeof inAppListResultSchema>;
|
||||
|
||||
export const unreadCountResultSchema = z.object({ count: z.number() });
|
||||
export type UnreadCountResult = z.infer<typeof unreadCountResultSchema>;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { oauthTokenRequestSchema, oauthConsentRequestSchema, oauthClientCreateRequestSchema } from './oauth.schema';
|
||||
|
||||
describe('oauthTokenRequestSchema', () => {
|
||||
it('is permissive across grant types and passes extras through', () => {
|
||||
expect(oauthTokenRequestSchema.safeParse({ grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' }).success).toBe(true);
|
||||
expect(oauthTokenRequestSchema.safeParse({ grant_type: 'client_credentials', client_id: 'c', client_secret: 's', scope: 'a b' }).success).toBe(true);
|
||||
expect(oauthTokenRequestSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauthConsentRequestSchema', () => {
|
||||
it('requires the PKCE consent fields + approved flag', () => {
|
||||
expect(oauthConsentRequestSchema.safeParse({ client_id: 'c', redirect_uri: 'u', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true }).success).toBe(true);
|
||||
expect(oauthConsentRequestSchema.safeParse({ client_id: 'c', redirect_uri: 'u', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('oauthClientCreateRequestSchema', () => {
|
||||
it('requires name + allowed_scopes', () => {
|
||||
expect(oauthClientCreateRequestSchema.safeParse({ name: 'CLI', allowed_scopes: ['trips:read'] }).success).toBe(true);
|
||||
expect(oauthClientCreateRequestSchema.safeParse({ name: 'CLI' }).success).toBe(false);
|
||||
expect(oauthClientCreateRequestSchema.safeParse({ allowed_scopes: [] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* OAuth 2.1 server contract for /oauth/* (public) + /api/oauth/* (SPA).
|
||||
*
|
||||
* The token endpoint accepts JSON or form-encoded bodies across three grant
|
||||
* types, so its body stays permissive (the service enforces grant-specific
|
||||
* rules + the RFC error codes). These schemas pin the consent submit and the
|
||||
* client-create body the SPA sends.
|
||||
*/
|
||||
export const oauthTokenRequestSchema = z
|
||||
.object({
|
||||
grant_type: z.string().optional(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
redirect_uri: z.string().optional(),
|
||||
code_verifier: z.string().optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
resource: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export type OauthTokenRequest = z.infer<typeof oauthTokenRequestSchema>;
|
||||
|
||||
export const oauthConsentRequestSchema = z.object({
|
||||
client_id: z.string(),
|
||||
redirect_uri: z.string(),
|
||||
scope: z.string(),
|
||||
state: z.string().optional(),
|
||||
code_challenge: z.string(),
|
||||
code_challenge_method: z.string(),
|
||||
approved: z.boolean(),
|
||||
resource: z.string().optional(),
|
||||
});
|
||||
export type OauthConsentRequest = z.infer<typeof oauthConsentRequestSchema>;
|
||||
|
||||
export const oauthClientCreateRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
redirect_uris: z.array(z.string()).optional(),
|
||||
allowed_scopes: z.array(z.string()),
|
||||
allows_client_credentials: z.boolean().optional(),
|
||||
});
|
||||
export type OauthClientCreateRequest = z.infer<typeof oauthClientCreateRequestSchema>;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { oidcCallbackQuerySchema, oidcExchangeQuerySchema } from './oidc.schema';
|
||||
|
||||
describe('oidcCallbackQuerySchema', () => {
|
||||
it('accepts code+state, an error, or nothing (all optional)', () => {
|
||||
expect(oidcCallbackQuerySchema.safeParse({ code: 'c', state: 's' }).success).toBe(true);
|
||||
expect(oidcCallbackQuerySchema.safeParse({ error: 'access_denied' }).success).toBe(true);
|
||||
expect(oidcCallbackQuerySchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('oidcExchangeQuerySchema', () => {
|
||||
it('requires a code', () => {
|
||||
expect(oidcExchangeQuerySchema.safeParse({ code: 'c' }).success).toBe(true);
|
||||
expect(oidcExchangeQuerySchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* OIDC SSO contract for /api/auth/oidc.
|
||||
*
|
||||
* The flow is redirect-based and carries no request bodies — inputs arrive as
|
||||
* query params (the provider callback's code/state/error, the optional invite on
|
||||
* /login, and the auth-code on /exchange). These schemas pin those query shapes;
|
||||
* the cryptographic verification + provisioning live in the OIDC service.
|
||||
*/
|
||||
export const oidcCallbackQuerySchema = z.object({
|
||||
code: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
export type OidcCallbackQuery = z.infer<typeof oidcCallbackQuerySchema>;
|
||||
|
||||
export const oidcExchangeQuerySchema = z.object({
|
||||
code: z.string(),
|
||||
});
|
||||
export type OidcExchangeQuery = z.infer<typeof oidcExchangeQuerySchema>;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
packingCreateItemRequestSchema,
|
||||
packingImportRequestSchema,
|
||||
packingCreateBagRequestSchema,
|
||||
packingSaveTemplateRequestSchema,
|
||||
} from './packing.schema';
|
||||
|
||||
describe('packingCreateItemRequestSchema', () => {
|
||||
it('requires a non-empty name; category/checked optional', () => {
|
||||
expect(packingCreateItemRequestSchema.safeParse({ name: 'Socks' }).success).toBe(true);
|
||||
expect(packingCreateItemRequestSchema.safeParse({ name: 'Socks', category: 'Clothes', checked: true }).success).toBe(true);
|
||||
expect(packingCreateItemRequestSchema.safeParse({ name: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('packingImportRequestSchema', () => {
|
||||
it('accepts an array of open item rows', () => {
|
||||
expect(packingImportRequestSchema.safeParse({ items: [{ name: 'a' }, { name: 'b', anything: 1 }] }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('packingCreateBagRequestSchema', () => {
|
||||
it('requires a name', () => {
|
||||
expect(packingCreateBagRequestSchema.safeParse({ name: 'Carry-on' }).success).toBe(true);
|
||||
expect(packingCreateBagRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('packingSaveTemplateRequestSchema', () => {
|
||||
it('requires a name', () => {
|
||||
expect(packingSaveTemplateRequestSchema.safeParse({ name: 'Summer' }).success).toBe(true);
|
||||
expect(packingSaveTemplateRequestSchema.safeParse({ name: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Packing API contract — single source of truth for the
|
||||
* /api/trips/:tripId/packing endpoints (items, bags, templates, assignees).
|
||||
*
|
||||
* Trip-scoped: every endpoint verifies trip access (404 "Trip not found") and
|
||||
* mutations additionally check the 'packing_edit' permission (403 "No
|
||||
* permission"). The legacy route (server/src/routes/packing.ts) wraps
|
||||
* services/packingService.ts; rows are DB-shaped and kept as open records here.
|
||||
* Mutations broadcast over WebSocket using the forwarded X-Socket-Id.
|
||||
*/
|
||||
|
||||
const open = z.record(z.string(), z.unknown());
|
||||
|
||||
export const packingCreateItemRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
category: z.string().optional(),
|
||||
checked: z.boolean().optional(),
|
||||
});
|
||||
export type PackingCreateItemRequest = z.infer<typeof packingCreateItemRequestSchema>;
|
||||
|
||||
export const packingUpdateItemRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
checked: z.boolean().optional(),
|
||||
category: z.string().optional(),
|
||||
weight_grams: z.number().nullable().optional(),
|
||||
bag_id: z.number().nullable().optional(),
|
||||
quantity: z.number().optional(),
|
||||
});
|
||||
export type PackingUpdateItemRequest = z.infer<typeof packingUpdateItemRequestSchema>;
|
||||
|
||||
export const packingImportRequestSchema = z.object({
|
||||
items: z.array(open),
|
||||
});
|
||||
export type PackingImportRequest = z.infer<typeof packingImportRequestSchema>;
|
||||
|
||||
export const packingReorderRequestSchema = z.object({
|
||||
orderedIds: z.array(z.number()),
|
||||
});
|
||||
export type PackingReorderRequest = z.infer<typeof packingReorderRequestSchema>;
|
||||
|
||||
export const packingCreateBagRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
color: z.string().optional(),
|
||||
});
|
||||
export type PackingCreateBagRequest = z.infer<typeof packingCreateBagRequestSchema>;
|
||||
|
||||
export const packingUpdateBagRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
weight_limit_grams: z.number().nullable().optional(),
|
||||
user_id: z.number().nullable().optional(),
|
||||
});
|
||||
export type PackingUpdateBagRequest = z.infer<typeof packingUpdateBagRequestSchema>;
|
||||
|
||||
export const packingBagMembersRequestSchema = z.object({
|
||||
user_ids: z.array(z.number()),
|
||||
});
|
||||
export type PackingBagMembersRequest = z.infer<typeof packingBagMembersRequestSchema>;
|
||||
|
||||
export const packingSaveTemplateRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
export type PackingSaveTemplateRequest = z.infer<typeof packingSaveTemplateRequestSchema>;
|
||||
|
||||
export const packingCategoryAssigneesRequestSchema = z.object({
|
||||
user_ids: z.array(z.number()),
|
||||
});
|
||||
export type PackingCategoryAssigneesRequest = z.infer<typeof packingCategoryAssigneesRequestSchema>;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
placeCreateRequestSchema,
|
||||
placeBulkDeleteRequestSchema,
|
||||
placeImportListRequestSchema,
|
||||
} from './place.schema';
|
||||
|
||||
describe('placeCreateRequestSchema', () => {
|
||||
it('requires a name and keeps the other place fields open', () => {
|
||||
expect(placeCreateRequestSchema.safeParse({ name: 'Spot', lat: 1, lng: 2, anything: true }).success).toBe(true);
|
||||
expect(placeCreateRequestSchema.safeParse({ lat: 1 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeBulkDeleteRequestSchema', () => {
|
||||
it('requires a numeric ids array', () => {
|
||||
expect(placeBulkDeleteRequestSchema.safeParse({ ids: [1, 2] }).success).toBe(true);
|
||||
expect(placeBulkDeleteRequestSchema.safeParse({ ids: ['a'] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeImportListRequestSchema', () => {
|
||||
it('requires a non-empty url', () => {
|
||||
expect(placeImportListRequestSchema.safeParse({ url: 'http://x' }).success).toBe(true);
|
||||
expect(placeImportListRequestSchema.safeParse({ url: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Place API contract — single source of truth for the /api/trips/:tripId/places
|
||||
* endpoints (place pool CRUD, GPX/map/list imports, image search, bulk delete).
|
||||
*
|
||||
* Trip-scoped; mutations use the 'place_edit' permission. The legacy route
|
||||
* (server/src/routes/places.ts) wraps placeService and fires the journey
|
||||
* place-created/updated/deleted hooks. Place rows are wide and provider-derived,
|
||||
* so create/update payloads stay mostly open with `name` pinned; string fields
|
||||
* are capped (name 200, description 2000, address 500, notes 2000) by the legacy
|
||||
* validateStringLengths, reproduced in the controller.
|
||||
*/
|
||||
|
||||
const open = z.record(z.string(), z.unknown());
|
||||
|
||||
export const placeCreateRequestSchema = open.and(z.object({ name: z.string().min(1) }));
|
||||
export type PlaceCreateRequest = z.infer<typeof placeCreateRequestSchema>;
|
||||
|
||||
export const placeUpdateRequestSchema = open;
|
||||
export type PlaceUpdateRequest = z.infer<typeof placeUpdateRequestSchema>;
|
||||
|
||||
export const placeBulkDeleteRequestSchema = z.object({
|
||||
ids: z.array(z.number()),
|
||||
});
|
||||
export type PlaceBulkDeleteRequest = z.infer<typeof placeBulkDeleteRequestSchema>;
|
||||
|
||||
export const placeImportListRequestSchema = z.object({
|
||||
url: z.string().min(1),
|
||||
});
|
||||
export type PlaceImportListRequest = z.infer<typeof placeImportListRequestSchema>;
|
||||
|
||||
/** Query filters for the place list. */
|
||||
export const placeListQuerySchema = z.object({
|
||||
search: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
tag: z.string().optional(),
|
||||
});
|
||||
export type PlaceListQuery = z.infer<typeof placeListQuerySchema>;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
reservationCreateRequestSchema,
|
||||
reservationPositionsRequestSchema,
|
||||
accommodationCreateRequestSchema,
|
||||
} from './reservation.schema';
|
||||
|
||||
describe('reservationCreateRequestSchema', () => {
|
||||
it('requires a title and keeps the other booking fields open', () => {
|
||||
expect(reservationCreateRequestSchema.safeParse({ title: 'Hotel', anything: 1, metadata: {} }).success).toBe(true);
|
||||
expect(reservationCreateRequestSchema.safeParse({ location: 'x' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reservationPositionsRequestSchema', () => {
|
||||
it('requires positions with id + day_plan_position', () => {
|
||||
expect(reservationPositionsRequestSchema.safeParse({ positions: [{ id: 1, day_plan_position: 0 }], day_id: 3 }).success).toBe(true);
|
||||
expect(reservationPositionsRequestSchema.safeParse({ positions: [{ id: 1 }] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('accommodationCreateRequestSchema', () => {
|
||||
it('requires place + start/end day; check-in/out optional', () => {
|
||||
expect(accommodationCreateRequestSchema.safeParse({ place_id: 2, start_day_id: 10, end_day_id: 11 }).success).toBe(true);
|
||||
expect(accommodationCreateRequestSchema.safeParse({ place_id: 2 }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Reservation + accommodation API contract — single source of truth for the
|
||||
* /api/trips/:tripId/reservations and /api/trips/:tripId/accommodations endpoints.
|
||||
*
|
||||
* Trip-scoped. Reservations use the 'reservation_edit' permission; accommodations
|
||||
* use 'day_edit' (they live in the day/accommodation service). The legacy routes
|
||||
* (server/src/routes/reservations.ts + the accommodations sub-router in
|
||||
* routes/days.ts) carry several side effects — auto-creating/updating/deleting a
|
||||
* linked budget item, accommodation broadcasts and booking notifications — which
|
||||
* the Nest service reproduces 1:1. Reservation bodies are wide and provider-ish,
|
||||
* so the create/update payloads stay mostly open with `title` pinned.
|
||||
*/
|
||||
|
||||
const open = z.record(z.string(), z.unknown());
|
||||
|
||||
/** Reservation create: title is required; the many optional fields stay open. */
|
||||
export const reservationCreateRequestSchema = open.and(z.object({ title: z.string().min(1) }));
|
||||
export type ReservationCreateRequest = z.infer<typeof reservationCreateRequestSchema>;
|
||||
|
||||
export const reservationUpdateRequestSchema = open;
|
||||
export type ReservationUpdateRequest = z.infer<typeof reservationUpdateRequestSchema>;
|
||||
|
||||
export const reservationPositionsRequestSchema = z.object({
|
||||
positions: z.array(z.object({ id: z.number(), day_plan_position: z.number() })),
|
||||
day_id: z.union([z.number(), z.string()]).nullable().optional(),
|
||||
});
|
||||
export type ReservationPositionsRequest = z.infer<typeof reservationPositionsRequestSchema>;
|
||||
|
||||
export const accommodationCreateRequestSchema = z.object({
|
||||
place_id: z.union([z.number(), z.string()]),
|
||||
start_day_id: z.union([z.number(), z.string()]),
|
||||
end_day_id: z.union([z.number(), z.string()]),
|
||||
check_in: z.string().nullable().optional(),
|
||||
check_in_end: z.string().nullable().optional(),
|
||||
check_out: z.string().nullable().optional(),
|
||||
confirmation: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
export type AccommodationCreateRequest = z.infer<typeof accommodationCreateRequestSchema>;
|
||||
|
||||
export const accommodationUpdateRequestSchema = open;
|
||||
export type AccommodationUpdateRequest = z.infer<typeof accommodationUpdateRequestSchema>;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sanitizeInlineHtml, sanitizeRichTextHtml, escapeHtml } from './sanitize'
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes the five metacharacters', () => {
|
||||
expect(escapeHtml(`a & b < c > d " e ' f`)).toBe('a & b < c > d " e ' f')
|
||||
})
|
||||
|
||||
it('escapes ampersands first (no double-escape of entities)', () => {
|
||||
expect(escapeHtml('<')).toBe('&lt;')
|
||||
})
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(escapeHtml('')).toBe('')
|
||||
})
|
||||
|
||||
it('leaves plain ASCII text untouched', () => {
|
||||
expect(escapeHtml('Paris Adventure 2026')).toBe('Paris Adventure 2026')
|
||||
})
|
||||
|
||||
it('neutralises a script tag without sanitising', () => {
|
||||
expect(escapeHtml('<script>alert(1)</script>')).toBe('<script>alert(1)</script>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeInlineHtml', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(sanitizeInlineHtml('')).toBe('')
|
||||
})
|
||||
|
||||
it('preserves the allowed inline tags', () => {
|
||||
expect(sanitizeInlineHtml('a <strong>b</strong> c')).toBe('a <strong>b</strong> c')
|
||||
expect(sanitizeInlineHtml('<em>x</em>')).toBe('<em>x</em>')
|
||||
})
|
||||
|
||||
it('strips <script> entirely', () => {
|
||||
const out = sanitizeInlineHtml('safe <script>alert(1)</script> text')
|
||||
expect(out).not.toContain('<script')
|
||||
expect(out).not.toContain('alert(1)')
|
||||
expect(out).toContain('safe')
|
||||
})
|
||||
|
||||
it('strips <img> (no img tag in inline allow-list)', () => {
|
||||
expect(sanitizeInlineHtml('<img src=x onerror=alert(1)>')).toBe('')
|
||||
})
|
||||
|
||||
it('strips on* event handlers from preserved tags', () => {
|
||||
const out = sanitizeInlineHtml('<span onclick="alert(1)">hi</span>')
|
||||
expect(out).not.toContain('onclick')
|
||||
expect(out).toContain('hi')
|
||||
})
|
||||
|
||||
it('strips style attribute (CSS-injection surface)', () => {
|
||||
const out = sanitizeInlineHtml('<span style="background:url(javascript:alert(1))">x</span>')
|
||||
expect(out).not.toContain('style=')
|
||||
expect(out).not.toContain('javascript:')
|
||||
})
|
||||
|
||||
it('strips iframe / object / embed / svg-with-script', () => {
|
||||
expect(sanitizeInlineHtml('<iframe src="evil"></iframe>')).toBe('')
|
||||
expect(sanitizeInlineHtml('<object data="evil"></object>')).toBe('')
|
||||
expect(sanitizeInlineHtml('<embed src="evil" />')).toBe('')
|
||||
expect(sanitizeInlineHtml('<svg><script>alert(1)</script></svg>')).not.toContain('script')
|
||||
})
|
||||
|
||||
it('does not preserve href / target on the inline tag set', () => {
|
||||
// <a> is not in the inline allow-list, so href can never appear here.
|
||||
const out = sanitizeInlineHtml('<a href="javascript:alert(1)">x</a>')
|
||||
expect(out).toBe('x')
|
||||
})
|
||||
|
||||
it('keeps user text content when the wrapping tag is stripped', () => {
|
||||
expect(sanitizeInlineHtml('<custom-tag>hello</custom-tag>')).toBe('hello')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeRichTextHtml', () => {
|
||||
it('preserves the full prose tag set', () => {
|
||||
const html = '<p>hello <strong>world</strong></p><ul><li>one</li></ul>'
|
||||
const out = sanitizeRichTextHtml(html)
|
||||
expect(out).toContain('<p>')
|
||||
expect(out).toContain('<strong>world</strong>')
|
||||
expect(out).toContain('<li>one</li>')
|
||||
})
|
||||
|
||||
it('still strips <script> + on* + style', () => {
|
||||
const out = sanitizeRichTextHtml('<p onclick="alert(1)" style="x">hi</p><script>x()</script>')
|
||||
expect(out).not.toContain('onclick')
|
||||
expect(out).not.toContain('style=')
|
||||
expect(out).not.toContain('<script')
|
||||
})
|
||||
|
||||
it('blocks javascript: hrefs', () => {
|
||||
const out = sanitizeRichTextHtml('<a href="javascript:alert(1)">x</a>')
|
||||
expect(out).not.toContain('javascript:')
|
||||
})
|
||||
|
||||
it('blocks data: hrefs that smuggle scripts', () => {
|
||||
const out = sanitizeRichTextHtml('<a href="data:text/html,<script>alert(1)</script>">x</a>')
|
||||
expect(out).not.toContain('data:text/html')
|
||||
})
|
||||
|
||||
it('keeps http(s) hrefs intact', () => {
|
||||
const out = sanitizeRichTextHtml('<a href="https://example.com">link</a>')
|
||||
expect(out).toContain('href="https://example.com"')
|
||||
})
|
||||
|
||||
it('strips disallowed tags but keeps their content', () => {
|
||||
expect(sanitizeRichTextHtml('<p>before<custom>middle</custom>after</p>')).toContain('middle')
|
||||
})
|
||||
|
||||
it('drops mathml + svg shorthand vectors', () => {
|
||||
const mathPayload = '<math><mtext><script>alert(1)</script></mtext></math>'
|
||||
const out = sanitizeRichTextHtml(mathPayload)
|
||||
expect(out).not.toContain('<script')
|
||||
expect(out).not.toContain('alert(1)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
|
||||
/**
|
||||
* HTML sanitisation for TREK.
|
||||
*
|
||||
* TREK currently has no rich-text editor and no user-provided HTML reaches
|
||||
* the database, so this module exists only to guard the handful of client
|
||||
* sites that interpolate user-controlled strings into a markup template
|
||||
* (today: the Journey suggestion banner). It is also the future home for
|
||||
* sanitisation if TipTap / Markdown ever ships.
|
||||
*
|
||||
* Why isomorphic-dompurify: works unchanged in browser (DOMPurify) and Node
|
||||
* (DOMPurify + jsdom). Tree-shakes correctly so the client bundle does not
|
||||
* pull jsdom.
|
||||
*/
|
||||
|
||||
// Inline-only tags. Block-level markup (paragraphs, lists, headings) is not
|
||||
// expected in the surfaces we render today, so we keep the allow-list minimal
|
||||
// and rely on `sanitizeRichTextHtml` when a richer surface needs full prose.
|
||||
const INLINE_TAGS = [
|
||||
'b', 'strong', 'i', 'em', 'u', 's', 'del', 'ins',
|
||||
'mark', 'code', 'sub', 'sup', 'br', 'span',
|
||||
] as const
|
||||
|
||||
const FULL_TAGS = [
|
||||
...INLINE_TAGS,
|
||||
'p', 'div', 'ul', 'ol', 'li',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'blockquote', 'pre', 'hr', 'a',
|
||||
] as const
|
||||
|
||||
const SAFE_ATTRIBUTES = ['href', 'rel', 'target'] as const
|
||||
|
||||
/**
|
||||
* Escapes the five HTML metacharacters so a raw string can be safely
|
||||
* interpolated into an HTML template. Use this BEFORE substitution when a
|
||||
* user-controlled value lands inside a markup-shaped translation string.
|
||||
*
|
||||
* This is *not* a substitute for `sanitizeInlineHtml`: escape input, then
|
||||
* sanitise the resulting template — both layers run together in `tHtml`.
|
||||
*/
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict inline sanitiser. Use for short, mostly-text strings that may include
|
||||
* basic emphasis (`<strong>`, `<em>`, …) — e.g. the Journey suggestion banner
|
||||
* where a translated template embeds a user-controlled trip title.
|
||||
*
|
||||
* Drops every tag outside the inline allow-list, strips all attributes, and
|
||||
* blocks every URL scheme except http/https/mailto/tel via DOMPurify's
|
||||
* built-in URL allow-list.
|
||||
*/
|
||||
export function sanitizeInlineHtml(html: string): string {
|
||||
if (!html) return ''
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: [...INLINE_TAGS],
|
||||
ALLOWED_ATTR: [],
|
||||
KEEP_CONTENT: true,
|
||||
ALLOW_DATA_ATTR: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissive rich-text sanitiser. Use when a surface legitimately renders a
|
||||
* prose document (lists, paragraphs, links). Keeps the same tag families as
|
||||
* the inline sanitiser plus block-level markup and anchors with safe attrs.
|
||||
*/
|
||||
export function sanitizeRichTextHtml(html: string): string {
|
||||
if (!html) return ''
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: [...FULL_TAGS],
|
||||
ALLOWED_ATTR: [...SAFE_ATTRIBUTES],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { settingUpsertRequestSchema, settingsBulkRequestSchema, MASKED_SETTING_VALUE } from './settings.schema';
|
||||
|
||||
describe('settingUpsertRequestSchema', () => {
|
||||
it('requires a key; value is any/optional', () => {
|
||||
expect(settingUpsertRequestSchema.safeParse({ key: 'theme', value: 'dark' }).success).toBe(true);
|
||||
expect(settingUpsertRequestSchema.safeParse({ key: 'theme' }).success).toBe(true);
|
||||
expect(settingUpsertRequestSchema.safeParse({ value: 'dark' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settingsBulkRequestSchema', () => {
|
||||
it('requires a settings record', () => {
|
||||
expect(settingsBulkRequestSchema.safeParse({ settings: { a: 1, b: 'x' } }).success).toBe(true);
|
||||
expect(settingsBulkRequestSchema.safeParse({ settings: {} }).success).toBe(true);
|
||||
expect(settingsBulkRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MASKED_SETTING_VALUE', () => {
|
||||
it('is the bullet sentinel the client echoes for unchanged secrets', () => {
|
||||
expect(MASKED_SETTING_VALUE).toBe('••••••••');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* User-settings API contract — per-user key/value preferences under
|
||||
* /api/settings (get all, upsert one, bulk upsert).
|
||||
*
|
||||
* Values are intentionally untyped (settings hold strings, numbers, booleans
|
||||
* and small objects). A masked value of '••••••••' on a single upsert is a
|
||||
* no-op sentinel (the client echoes the masked secret back unchanged).
|
||||
*/
|
||||
export const MASKED_SETTING_VALUE = '••••••••';
|
||||
|
||||
export const settingUpsertRequestSchema = z.object({
|
||||
key: z.string().min(1),
|
||||
value: z.unknown().optional(),
|
||||
});
|
||||
export type SettingUpsertRequest = z.infer<typeof settingUpsertRequestSchema>;
|
||||
|
||||
export const settingsBulkRequestSchema = z.object({
|
||||
settings: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
export type SettingsBulkRequest = z.infer<typeof settingsBulkRequestSchema>;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { shareLinkRequestSchema } from './share.schema';
|
||||
|
||||
describe('shareLinkRequestSchema', () => {
|
||||
it('accepts any subset of section toggles (all optional) and an empty body', () => {
|
||||
expect(shareLinkRequestSchema.safeParse({ share_map: true, share_budget: false }).success).toBe(true);
|
||||
expect(shareLinkRequestSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a non-boolean toggle', () => {
|
||||
expect(shareLinkRequestSchema.safeParse({ share_map: 'yes' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Trip share-link API contract.
|
||||
*
|
||||
* Owner/members create a public read-only token for a trip under
|
||||
* /api/trips/:tripId/share-link (gated by 'share_manage'); anyone can read the
|
||||
* shared snapshot at /api/shared/:token (no auth). The per-section toggles
|
||||
* default server-side (map/bookings on, packing/budget/collab off), so every
|
||||
* field is optional here.
|
||||
*/
|
||||
export const shareLinkRequestSchema = z.object({
|
||||
share_map: z.boolean().optional(),
|
||||
share_bookings: z.boolean().optional(),
|
||||
share_packing: z.boolean().optional(),
|
||||
share_budget: z.boolean().optional(),
|
||||
share_collab: z.boolean().optional(),
|
||||
});
|
||||
export type ShareLinkRequest = z.infer<typeof shareLinkRequestSchema>;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { systemNoticeDtoSchema } from './system-notice.schema';
|
||||
|
||||
describe('systemNoticeDtoSchema', () => {
|
||||
it('accepts a minimal notice (required fields only)', () => {
|
||||
const parsed = systemNoticeDtoSchema.parse({
|
||||
id: 'welcome', display: 'modal', severity: 'info',
|
||||
titleKey: 'notice.welcome.title', bodyKey: 'notice.welcome.body', dismissible: true,
|
||||
});
|
||||
expect(parsed.id).toBe('welcome');
|
||||
});
|
||||
|
||||
it('accepts a rich notice with media, highlights and a nav CTA', () => {
|
||||
expect(systemNoticeDtoSchema.safeParse({
|
||||
id: 'release', display: 'banner', severity: 'warn',
|
||||
titleKey: 't', bodyKey: 'b', dismissible: false,
|
||||
bodyParams: { version: '3.1' },
|
||||
icon: 'sparkles',
|
||||
media: { src: '/img.png', altKey: 'alt', placement: 'hero' },
|
||||
highlights: [{ labelKey: 'h1', iconName: 'check' }],
|
||||
cta: { kind: 'nav', labelKey: 'open', href: '/whats-new' },
|
||||
}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an action CTA with the discriminated-union shape', () => {
|
||||
expect(systemNoticeDtoSchema.safeParse({
|
||||
id: 'x', display: 'toast', severity: 'critical',
|
||||
titleKey: 't', bodyKey: 'b', dismissible: true,
|
||||
cta: { kind: 'action', labelKey: 'do', actionId: 'reload', dismissOnAction: true },
|
||||
}).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown display value and a malformed CTA', () => {
|
||||
expect(systemNoticeDtoSchema.safeParse({
|
||||
id: 'x', display: 'popup', severity: 'info', titleKey: 't', bodyKey: 'b', dismissible: true,
|
||||
}).success).toBe(false);
|
||||
expect(systemNoticeDtoSchema.safeParse({
|
||||
id: 'x', display: 'modal', severity: 'info', titleKey: 't', bodyKey: 'b', dismissible: true,
|
||||
cta: { kind: 'nav', labelKey: 'open' },
|
||||
}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* System-notice API contract — the /api/system-notices endpoints.
|
||||
*
|
||||
* Notices are server-side announcements (release notes, onboarding hints, ...)
|
||||
* defined in a static registry. The server evaluates each notice's conditions
|
||||
* for the current user and returns only the active, non-dismissed ones, sorted
|
||||
* by priority/severity/date. The DTO sent to the client is the notice minus the
|
||||
* server-only fields (conditions, publishedAt, version bounds, priority) — see
|
||||
* SystemNoticeDTO in server/src/systemNotices/types.ts, which this mirrors.
|
||||
*
|
||||
* The bespoke 404 `{ error: 'NOTICE_NOT_FOUND' }` body and the 204 dismiss
|
||||
* response are reproduced in the controller, not derived from this schema.
|
||||
*/
|
||||
|
||||
export const noticeDisplaySchema = z.enum(['modal', 'banner', 'toast']);
|
||||
export const noticeSeveritySchema = z.enum(['info', 'warn', 'critical']);
|
||||
|
||||
const noticeMediaSchema = z.object({
|
||||
src: z.string(),
|
||||
srcDark: z.string().optional(),
|
||||
altKey: z.string(),
|
||||
placement: z.enum(['hero', 'inline']).optional(),
|
||||
aspectRatio: z.string().optional(),
|
||||
});
|
||||
|
||||
const noticeHighlightSchema = z.object({
|
||||
labelKey: z.string(),
|
||||
iconName: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Call-to-action: either a navigation link or an in-app action. */
|
||||
const noticeCtaSchema = z.discriminatedUnion('kind', [
|
||||
z.object({ kind: z.literal('nav'), labelKey: z.string(), href: z.string() }),
|
||||
z.object({
|
||||
kind: z.literal('action'),
|
||||
labelKey: z.string(),
|
||||
actionId: z.string(),
|
||||
dismissOnAction: z.boolean().optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** The client-facing notice (server-evaluated; conditions/versioning stripped). */
|
||||
export const systemNoticeDtoSchema = z.object({
|
||||
id: z.string(),
|
||||
display: noticeDisplaySchema,
|
||||
severity: noticeSeveritySchema,
|
||||
titleKey: z.string(),
|
||||
bodyKey: z.string(),
|
||||
bodyParams: z.record(z.string(), z.string()).optional(),
|
||||
icon: z.string().optional(),
|
||||
media: noticeMediaSchema.optional(),
|
||||
highlights: z.array(noticeHighlightSchema).optional(),
|
||||
cta: noticeCtaSchema.optional(),
|
||||
dismissible: z.boolean(),
|
||||
});
|
||||
export type SystemNoticeDto = z.infer<typeof systemNoticeDtoSchema>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { tagSchema, createTagRequestSchema, updateTagRequestSchema } from './tag.schema';
|
||||
|
||||
describe('tagSchema', () => {
|
||||
it('accepts a full tag', () => {
|
||||
expect(tagSchema.safeParse({ id: 1, user_id: 5, name: 'Beach', color: '#10b981' }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTagRequestSchema', () => {
|
||||
it('requires a non-empty name; colour optional', () => {
|
||||
expect(createTagRequestSchema.safeParse({ name: 'Beach' }).success).toBe(true);
|
||||
expect(createTagRequestSchema.safeParse({ name: '' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTagRequestSchema', () => {
|
||||
it('allows every field to be omitted', () => {
|
||||
expect(updateTagRequestSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Tag API contract — single source of truth for the /api/tags endpoints.
|
||||
*
|
||||
* Tags are per-user place labels (used for filtering). Unlike categories they
|
||||
* are NOT admin-gated: every endpoint is scoped to the authenticated user's own
|
||||
* tags. The legacy route (server/src/routes/tags.ts) wraps services/tagService.ts
|
||||
* 1:1; update/delete first verify ownership via getTagByIdAndUser, 404ing
|
||||
* otherwise.
|
||||
*
|
||||
* The bespoke 400 ("Tag name is required") and 404 ("Tag not found") messages are
|
||||
* reproduced in the controller so the bodies stay byte-identical.
|
||||
*/
|
||||
|
||||
export const tagSchema = z.object({
|
||||
id: z.number(),
|
||||
user_id: z.number(),
|
||||
name: z.string(),
|
||||
color: z.string(),
|
||||
created_at: z.string().optional(),
|
||||
});
|
||||
export type Tag = z.infer<typeof tagSchema>;
|
||||
|
||||
export const createTagRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
color: z.string().optional(),
|
||||
});
|
||||
export type CreateTagRequest = z.infer<typeof createTagRequestSchema>;
|
||||
|
||||
/** Both fields optional — the service COALESCEs each against the stored value. */
|
||||
export const updateTagRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
});
|
||||
export type UpdateTagRequest = z.infer<typeof updateTagRequestSchema>;
|
||||
|
||||
export const tagListResponseSchema = z.object({
|
||||
tags: z.array(tagSchema),
|
||||
});
|
||||
export type TagListResponse = z.infer<typeof tagListResponseSchema>;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
todoCreateItemRequestSchema,
|
||||
todoUpdateItemRequestSchema,
|
||||
todoReorderRequestSchema,
|
||||
} from './todo.schema';
|
||||
|
||||
describe('todoCreateItemRequestSchema', () => {
|
||||
it('requires a name; metadata optional with the service shapes', () => {
|
||||
expect(todoCreateItemRequestSchema.safeParse({ name: 'Book hotel' }).success).toBe(true);
|
||||
expect(todoCreateItemRequestSchema.safeParse({ name: 'X', due_date: '2026-07-01', priority: 2, assigned_user_id: 3 }).success).toBe(true);
|
||||
expect(todoCreateItemRequestSchema.safeParse({ name: '' }).success).toBe(false);
|
||||
// priority is numeric (matches the service), not a string
|
||||
expect(todoCreateItemRequestSchema.safeParse({ name: 'X', priority: 'high' }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('todoUpdateItemRequestSchema', () => {
|
||||
it('allows every field to be omitted and accepts checked', () => {
|
||||
expect(todoUpdateItemRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(todoUpdateItemRequestSchema.safeParse({ checked: true }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('todoReorderRequestSchema', () => {
|
||||
it('requires an array of numeric ids', () => {
|
||||
expect(todoReorderRequestSchema.safeParse({ orderedIds: [1, 2, 3] }).success).toBe(true);
|
||||
expect(todoReorderRequestSchema.safeParse({ orderedIds: ['a'] }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* To-do API contract — single source of truth for the /api/trips/:tripId/todo
|
||||
* endpoints (trip task list with categories + assignees).
|
||||
*
|
||||
* Trip-scoped like packing: every endpoint verifies trip access (404 "Trip not
|
||||
* found") and mutations check the same 'packing_edit' permission the legacy route
|
||||
* uses (403 "No permission"). Rows are DB-shaped and kept open. Mutations
|
||||
* broadcast over WebSocket with the forwarded X-Socket-Id.
|
||||
*/
|
||||
|
||||
export const todoCreateItemRequestSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
category: z.string().optional(),
|
||||
due_date: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
assigned_user_id: z.number().optional(),
|
||||
priority: z.number().optional(),
|
||||
});
|
||||
export type TodoCreateItemRequest = z.infer<typeof todoCreateItemRequestSchema>;
|
||||
|
||||
export const todoUpdateItemRequestSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
checked: z.boolean().optional(),
|
||||
category: z.string().optional(),
|
||||
due_date: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
assigned_user_id: z.number().optional(),
|
||||
priority: z.number().optional(),
|
||||
});
|
||||
export type TodoUpdateItemRequest = z.infer<typeof todoUpdateItemRequestSchema>;
|
||||
|
||||
export const todoReorderRequestSchema = z.object({
|
||||
orderedIds: z.array(z.number()),
|
||||
});
|
||||
export type TodoReorderRequest = z.infer<typeof todoReorderRequestSchema>;
|
||||
|
||||
export const todoCategoryAssigneesRequestSchema = z.object({
|
||||
user_ids: z.array(z.number()),
|
||||
});
|
||||
export type TodoCategoryAssigneesRequest = z.infer<typeof todoCategoryAssigneesRequestSchema>;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
tripCreateRequestSchema,
|
||||
tripUpdateRequestSchema,
|
||||
tripAddMemberRequestSchema,
|
||||
} from './trip.schema';
|
||||
|
||||
describe('tripCreateRequestSchema', () => {
|
||||
it('requires a title; dates/currency/reminder optional', () => {
|
||||
expect(tripCreateRequestSchema.safeParse({ title: 'Japan' }).success).toBe(true);
|
||||
expect(tripCreateRequestSchema.safeParse({ title: 'Japan', start_date: '2026-07-01', day_count: 7 }).success).toBe(true);
|
||||
expect(tripCreateRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tripUpdateRequestSchema', () => {
|
||||
it('is fully partial and accepts is_archived + cover_image', () => {
|
||||
expect(tripUpdateRequestSchema.safeParse({}).success).toBe(true);
|
||||
expect(tripUpdateRequestSchema.safeParse({ is_archived: 1, cover_image: null }).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tripAddMemberRequestSchema', () => {
|
||||
it('requires an identifier', () => {
|
||||
expect(tripAddMemberRequestSchema.safeParse({ identifier: 'bob@x.y' }).success).toBe(true);
|
||||
expect(tripAddMemberRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Trip API contract — single source of truth for the /api/trips aggregate-root
|
||||
* endpoints (list/create/get/update/delete a trip, cover upload, copy, members,
|
||||
* offline bundle, ICS export).
|
||||
*
|
||||
* The aggregate root shares its path with the trip sub-domains (days, places,
|
||||
* collab, files, ...), so in the strangler it uses EXACT prefixes (`/api/trips|`,
|
||||
* `/api/trips/:tripId|`) plus the specific sub-route prefixes — never a broad
|
||||
* `/api/trips`, which would swallow not-yet-migrated nested mounts. The legacy
|
||||
* route (server/src/routes/trips.ts) wraps tripService and does per-field
|
||||
* permission checks + audit logging. Trip rows are wide, so responses stay open.
|
||||
*/
|
||||
|
||||
export const tripCreateRequestSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().nullable().optional(),
|
||||
start_date: z.string().nullable().optional(),
|
||||
end_date: z.string().nullable().optional(),
|
||||
currency: z.string().optional(),
|
||||
reminder_days: z.number().optional(),
|
||||
day_count: z.number().optional(),
|
||||
});
|
||||
export type TripCreateRequest = z.infer<typeof tripCreateRequestSchema>;
|
||||
|
||||
/** Update is partial; the route runs per-field permission checks on what's present. */
|
||||
export const tripUpdateRequestSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
start_date: z.string().nullable().optional(),
|
||||
end_date: z.string().nullable().optional(),
|
||||
currency: z.string().optional(),
|
||||
reminder_days: z.number().optional(),
|
||||
day_count: z.number().optional(),
|
||||
is_archived: z.union([z.boolean(), z.number()]).optional(),
|
||||
cover_image: z.string().nullable().optional(),
|
||||
});
|
||||
export type TripUpdateRequest = z.infer<typeof tripUpdateRequestSchema>;
|
||||
|
||||
export const tripCopyRequestSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
});
|
||||
export type TripCopyRequest = z.infer<typeof tripCopyRequestSchema>;
|
||||
|
||||
export const tripAddMemberRequestSchema = z.object({
|
||||
identifier: z.string(),
|
||||
});
|
||||
export type TripAddMemberRequest = z.infer<typeof tripAddMemberRequestSchema>;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
vacayAddHolidayCalendarRequestSchema,
|
||||
vacayInviteRequestSchema,
|
||||
vacayToggleEntryRequestSchema,
|
||||
vacayAddYearRequestSchema,
|
||||
} from './vacay.schema';
|
||||
|
||||
describe('vacayAddHolidayCalendarRequestSchema', () => {
|
||||
it('requires a region; label/color/sort_order optional', () => {
|
||||
expect(vacayAddHolidayCalendarRequestSchema.safeParse({ region: 'DE-BY' }).success).toBe(true);
|
||||
expect(vacayAddHolidayCalendarRequestSchema.safeParse({ region: 'DE-BY', label: null }).success).toBe(true);
|
||||
expect(vacayAddHolidayCalendarRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vacayInviteRequestSchema', () => {
|
||||
it('accepts a numeric or string user_id', () => {
|
||||
expect(vacayInviteRequestSchema.safeParse({ user_id: 2 }).success).toBe(true);
|
||||
expect(vacayInviteRequestSchema.safeParse({ user_id: '2' }).success).toBe(true);
|
||||
expect(vacayInviteRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vacayToggleEntryRequestSchema', () => {
|
||||
it('requires a date; target_user_id optional', () => {
|
||||
expect(vacayToggleEntryRequestSchema.safeParse({ date: '2026-07-01' }).success).toBe(true);
|
||||
expect(vacayToggleEntryRequestSchema.safeParse({ date: '2026-07-01', target_user_id: 3 }).success).toBe(true);
|
||||
expect(vacayToggleEntryRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vacayAddYearRequestSchema', () => {
|
||||
it('accepts a numeric or string year', () => {
|
||||
expect(vacayAddYearRequestSchema.safeParse({ year: 2027 }).success).toBe(true);
|
||||
expect(vacayAddYearRequestSchema.safeParse({ year: '2027' }).success).toBe(true);
|
||||
expect(vacayAddYearRequestSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Vacay API contract — single source of truth for the /api/addons/vacay endpoints
|
||||
* (shared vacation-day planner: plan, holiday calendars, members/invites, years,
|
||||
* entries, stats, public-holiday lookups).
|
||||
*
|
||||
* Parity note: like atlas, the legacy vacay route is NOT addon-gated at the mount
|
||||
* (app.ts), so the migration adds no gate. Plan/entry/stats shapes are wide and
|
||||
* DB-derived, so the response schemas stay open records; the request schemas and
|
||||
* the bespoke 400/403/404/502 controller messages pin the client-facing parts.
|
||||
*
|
||||
* Many mutations carry an `X-Socket-Id` header that the services use to suppress
|
||||
* the echo broadcast to the originating client — it is forwarded unchanged.
|
||||
*/
|
||||
|
||||
const open = z.record(z.string(), z.unknown());
|
||||
|
||||
export const vacayAddHolidayCalendarRequestSchema = z.object({
|
||||
region: z.string().min(1),
|
||||
label: z.string().nullable().optional(),
|
||||
color: z.string().optional(),
|
||||
sort_order: z.number().optional(),
|
||||
});
|
||||
export type VacayAddHolidayCalendarRequest = z.infer<typeof vacayAddHolidayCalendarRequestSchema>;
|
||||
|
||||
export const vacaySetColorRequestSchema = z.object({
|
||||
color: z.string().optional(),
|
||||
target_user_id: z.union([z.number(), z.string()]).optional(),
|
||||
});
|
||||
export type VacaySetColorRequest = z.infer<typeof vacaySetColorRequestSchema>;
|
||||
|
||||
export const vacayInviteRequestSchema = z.object({
|
||||
user_id: z.union([z.number(), z.string()]),
|
||||
});
|
||||
export type VacayInviteRequest = z.infer<typeof vacayInviteRequestSchema>;
|
||||
|
||||
export const vacayInviteActionRequestSchema = z.object({
|
||||
plan_id: z.number().optional(),
|
||||
});
|
||||
export type VacayInviteActionRequest = z.infer<typeof vacayInviteActionRequestSchema>;
|
||||
|
||||
export const vacayAddYearRequestSchema = z.object({
|
||||
year: z.union([z.number(), z.string()]),
|
||||
});
|
||||
export type VacayAddYearRequest = z.infer<typeof vacayAddYearRequestSchema>;
|
||||
|
||||
export const vacayToggleEntryRequestSchema = z.object({
|
||||
date: z.string().min(1),
|
||||
target_user_id: z.union([z.number(), z.string()]).optional(),
|
||||
});
|
||||
export type VacayToggleEntryRequest = z.infer<typeof vacayToggleEntryRequestSchema>;
|
||||
|
||||
export const vacayCompanyHolidayRequestSchema = z.object({
|
||||
date: z.string(),
|
||||
note: z.string().optional(),
|
||||
});
|
||||
export type VacayCompanyHolidayRequest = z.infer<typeof vacayCompanyHolidayRequestSchema>;
|
||||
|
||||
export const vacayUpdateStatsRequestSchema = z.object({
|
||||
vacation_days: z.number().optional(),
|
||||
target_user_id: z.union([z.number(), z.string()]).optional(),
|
||||
});
|
||||
export type VacayUpdateStatsRequest = z.infer<typeof vacayUpdateStatsRequestSchema>;
|
||||
|
||||
/** Plan / entries / stats payloads are wide and DB-derived; kept open. */
|
||||
export const vacayPlanDataSchema = open;
|
||||
export type VacayPlanData = z.infer<typeof vacayPlanDataSchema>;
|
||||
Reference in New Issue
Block a user