Files
oikos/server/openapi.js
2026-05-11 23:08:59 +02:00

929 lines
39 KiB
JavaScript

function authSecurity() {
return [{ bearerAuth: [] }, { apiKeyAuth: [] }, { cookieAuth: [] }];
}
function csrfHeaderParam() {
return {
name: 'X-CSRF-Token',
in: 'header',
required: false,
description: 'Required for state-changing requests when using session/cookie authentication. Not required for API-token authentication.',
schema: { type: 'string' },
};
}
function jsonBody(schemaRef, description = 'JSON request body') {
return {
required: true,
description,
content: {
'application/json': {
schema: schemaRef ? { $ref: schemaRef } : { type: 'object', additionalProperties: true },
},
},
};
}
function op({
summary,
tag,
description,
auth = true,
admin = false,
params = [],
requestBody = null,
responses = null,
stateChanging = false,
}) {
const operation = {
tags: [tag],
summary,
responses: responses ?? {
200: { description: 'Successful response' },
401: { $ref: '#/components/responses/Unauthorized' },
500: { $ref: '#/components/responses/InternalServerError' },
},
};
if (description) operation.description = description;
if (auth) operation.security = authSecurity();
if (admin) {
operation.description = `${operation.description ? `${operation.description}\n\n` : ''}Admin-only endpoint.`;
operation.responses[403] = { $ref: '#/components/responses/Forbidden' };
}
if (params.length || stateChanging) {
operation.parameters = [...params];
if (stateChanging) operation.parameters.push(csrfHeaderParam());
}
if (requestBody) operation.requestBody = requestBody;
return operation;
}
function idParam(name = 'id', description = 'Resource ID') {
return {
name,
in: 'path',
required: true,
description,
schema: { type: 'integer' },
};
}
function langParam() {
return {
name: 'lang',
in: 'query',
required: false,
description: 'Language code for localized labels. Supported values: ar, de, el, en, es, fr, hi, it, ja, pt, ru, sv, tr, uk, zh. Defaults to en.',
schema: {
type: 'string',
default: 'en',
enum: ['ar', 'de', 'el', 'en', 'es', 'fr', 'hi', 'it', 'ja', 'pt', 'ru', 'sv', 'tr', 'uk', 'zh'],
},
};
}
function buildPaths() {
return {
'/health': {
get: op({
summary: 'Health check',
tag: 'System',
auth: false,
responses: {
200: {
description: 'Service health status',
content: { 'application/json': { schema: { $ref: '#/components/schemas/HealthResponse' } } },
},
},
}),
},
'/api/v1/version': {
get: op({
summary: 'Get application version',
tag: 'System',
auth: false,
responses: {
200: {
description: 'Application version',
content: { 'application/json': { schema: { $ref: '#/components/schemas/VersionResponse' } } },
},
},
}),
},
'/api/v1/openapi.json': {
get: op({
summary: 'Get OpenAPI specification',
tag: 'System',
auth: false,
description: 'Use `?download=1` to receive the OpenAPI document as a downloadable file.',
}),
},
'/openapi.json': {
get: op({
summary: 'Get OpenAPI specification',
tag: 'System',
auth: false,
description: 'Alias for `/api/v1/openapi.json`. Use `?download=1` to download the JSON file.',
}),
},
'/docs': {
get: op({
summary: 'Swagger UI documentation',
tag: 'System',
auth: false,
responses: { 200: { description: 'Swagger UI HTML page' } },
}),
},
'/api/v1/auth/login': {
post: op({
summary: 'Login with username and password',
tag: 'Auth',
auth: false,
requestBody: jsonBody('#/components/schemas/LoginRequest'),
responses: {
200: {
description: 'Authenticated user and CSRF token',
content: { 'application/json': { schema: { $ref: '#/components/schemas/LoginResponse' } } },
},
401: { $ref: '#/components/responses/Unauthorized' },
},
}),
},
'/api/v1/auth/logout': {
post: op({ summary: 'Logout current session', tag: 'Auth', stateChanging: true }),
},
'/api/v1/auth/setup': {
post: op({
summary: 'Initial setup: create first admin',
tag: 'Auth',
auth: false,
requestBody: jsonBody('#/components/schemas/SetupRequest'),
responses: {
201: { description: 'Admin user created' },
403: { $ref: '#/components/responses/Forbidden' },
409: { description: 'Username already taken' },
},
}),
},
'/api/v1/auth/me': {
get: op({
summary: 'Get current authenticated user',
tag: 'Auth',
responses: {
200: {
description: 'Current user',
content: { 'application/json': { schema: { $ref: '#/components/schemas/MeResponse' } } },
},
401: { $ref: '#/components/responses/Unauthorized' },
},
}),
},
'/api/v1/auth/me/password': {
patch: op({
summary: 'Change current user password',
tag: 'Auth',
stateChanging: true,
requestBody: jsonBody('#/components/schemas/PasswordChangeRequest'),
}),
},
'/api/v1/auth/me/profile': {
patch: op({
summary: 'Update current user profile',
tag: 'Auth',
stateChanging: true,
requestBody: jsonBody('#/components/schemas/ProfileUpdateRequest'),
}),
},
'/api/v1/auth/users': {
get: op({ summary: 'List users', tag: 'Auth', admin: true }),
post: op({
summary: 'Create user',
tag: 'Auth',
admin: true,
stateChanging: true,
requestBody: jsonBody('#/components/schemas/UserCreateRequest'),
responses: {
201: { description: 'User created' },
400: { $ref: '#/components/responses/BadRequest' },
403: { $ref: '#/components/responses/Forbidden' },
409: { description: 'Username already taken' },
500: { $ref: '#/components/responses/InternalServerError' },
},
}),
},
'/api/v1/auth/users/{id}': {
patch: op({
summary: 'Update user',
tag: 'Auth',
admin: true,
stateChanging: true,
params: [idParam('id', 'User ID')],
requestBody: jsonBody('#/components/schemas/UserUpdateRequest'),
}),
delete: op({
summary: 'Delete user',
tag: 'Auth',
admin: true,
stateChanging: true,
params: [idParam('id', 'User ID')],
}),
},
'/api/v1/auth/api-tokens': {
get: op({ summary: 'List API tokens', tag: 'Auth', admin: true }),
post: op({
summary: 'Create API token',
tag: 'Auth',
admin: true,
stateChanging: true,
requestBody: jsonBody('#/components/schemas/ApiTokenCreateRequest'),
responses: {
201: {
description: 'API token created. The plaintext token is returned only once.',
content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiTokenCreateResponse' } } },
},
400: { $ref: '#/components/responses/BadRequest' },
403: { $ref: '#/components/responses/Forbidden' },
500: { $ref: '#/components/responses/InternalServerError' },
},
}),
},
'/api/v1/auth/api-tokens/{id}': {
delete: op({
summary: 'Revoke API token',
tag: 'Auth',
admin: true,
stateChanging: true,
params: [idParam('id', 'API token ID')],
}),
},
'/api/v1/family/members': {
get: op({
summary: 'List family members',
tag: 'Family',
description: 'Read-only endpoint for family-member profiles. It does not expose usernames or system access roles and does not support create/update/delete operations.',
responses: {
200: {
description: 'Family members',
content: { 'application/json': { schema: { $ref: '#/components/schemas/FamilyMembersResponse' } } },
},
401: { $ref: '#/components/responses/Unauthorized' },
500: { $ref: '#/components/responses/InternalServerError' },
},
}),
},
'/api/v1/backup/status': {
get: op({
summary: 'Get backup status',
tag: 'Backup',
admin: true,
}),
},
'/api/v1/backup/database': {
get: op({
summary: 'Download database backup',
tag: 'Backup',
admin: true,
responses: {
200: {
description: 'SQLite database backup file',
content: {
'application/octet-stream': {
schema: { type: 'string', format: 'binary' },
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
500: { $ref: '#/components/responses/InternalServerError' },
},
}),
},
'/api/v1/backup/restore': {
post: op({
summary: 'Restore database backup',
tag: 'Backup',
admin: true,
stateChanging: true,
requestBody: {
required: true,
description: 'Raw SQLite database backup file.',
content: {
'application/octet-stream': {
schema: { type: 'string', format: 'binary' },
},
},
},
responses: {
200: { description: 'Database restored' },
400: { $ref: '#/components/responses/BadRequest' },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
500: { $ref: '#/components/responses/InternalServerError' },
},
}),
},
'/api/v1/dashboard': { get: op({ summary: 'Get dashboard data', tag: 'Dashboard' }) },
'/api/v1/tasks': {
get: op({ summary: 'List tasks', tag: 'Tasks' }),
post: op({ summary: 'Create task', tag: 'Tasks', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/tasks/meta/options': { get: op({ summary: 'Get task metadata', tag: 'Tasks' }) },
'/api/v1/tasks/{id}': {
get: op({ summary: 'Get task', tag: 'Tasks', params: [idParam()] }),
put: op({ summary: 'Update task', tag: 'Tasks', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete task', tag: 'Tasks', params: [idParam()], stateChanging: true }),
},
'/api/v1/tasks/{id}/status': {
patch: op({ summary: 'Update task status', tag: 'Tasks', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/shopping': {
get: op({ summary: 'List shopping lists', tag: 'Shopping' }),
post: op({ summary: 'Create shopping list', tag: 'Shopping', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/shopping/categories': {
get: op({ summary: 'List shopping categories', tag: 'Shopping' }),
post: op({ summary: 'Create shopping category', tag: 'Shopping', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/shopping/categories/{catId}': {
put: op({ summary: 'Update shopping category', tag: 'Shopping', params: [idParam('catId', 'Category ID')], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete shopping category', tag: 'Shopping', params: [idParam('catId', 'Category ID')], stateChanging: true }),
},
'/api/v1/shopping/categories/reorder': {
patch: op({ summary: 'Reorder shopping categories', tag: 'Shopping', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/shopping/suggestions': { get: op({ summary: 'Get shopping suggestions', tag: 'Shopping' }) },
'/api/v1/shopping/items/{itemId}': {
patch: op({ summary: 'Update shopping item', tag: 'Shopping', params: [idParam('itemId', 'Item ID')], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete shopping item', tag: 'Shopping', params: [idParam('itemId', 'Item ID')], stateChanging: true }),
},
'/api/v1/shopping/{listId}': {
put: op({ summary: 'Rename shopping list', tag: 'Shopping', params: [idParam('listId', 'List ID')], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete shopping list', tag: 'Shopping', params: [idParam('listId', 'List ID')], stateChanging: true }),
},
'/api/v1/shopping/{listId}/items': {
get: op({ summary: 'List items in shopping list', tag: 'Shopping', params: [idParam('listId', 'List ID')] }),
post: op({ summary: 'Add item to shopping list', tag: 'Shopping', params: [idParam('listId', 'List ID')], stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/shopping/{listId}/items/checked': {
delete: op({ summary: 'Delete checked shopping items', tag: 'Shopping', params: [idParam('listId', 'List ID')], stateChanging: true }),
},
'/api/v1/meals': {
get: op({ summary: 'List meal plan entries', tag: 'Meals' }),
post: op({ summary: 'Create meal plan entry', tag: 'Meals', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/meals/suggestions': { get: op({ summary: 'Get meal suggestions', tag: 'Meals' }) },
'/api/v1/meals/{id}': {
put: op({ summary: 'Update meal plan entry', tag: 'Meals', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete meal plan entry', tag: 'Meals', params: [idParam()], stateChanging: true }),
},
'/api/v1/meals/{id}/ingredients': {
post: op({ summary: 'Add meal ingredient', tag: 'Meals', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/meals/ingredients/{ingId}': {
patch: op({ summary: 'Update meal ingredient', tag: 'Meals', params: [idParam('ingId', 'Ingredient ID')], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete meal ingredient', tag: 'Meals', params: [idParam('ingId', 'Ingredient ID')], stateChanging: true }),
},
'/api/v1/meals/{id}/to-shopping-list': {
post: op({ summary: 'Transfer meal ingredients to shopping list', tag: 'Meals', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/meals/week-to-shopping-list': {
post: op({ summary: 'Transfer weekly meal ingredients to shopping list', tag: 'Meals', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/meal-planning/cooking-rules': {
get: op({ summary: 'List recurring meal cook rules', tag: 'Meal Planning' }),
put: op({ summary: 'Replace recurring meal cook rules', tag: 'Meal Planning', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/meal-planning/recipe-signals': {
get: op({ summary: 'List family recipe preference/capability signals', tag: 'Meal Planning' }),
},
'/api/v1/meal-planning/recipe-signals/{recipeId}': {
put: op({ summary: 'Upsert family recipe preference/capability signal', tag: 'Meal Planning', params: [idParam('recipeId', 'Recipe ID')], stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/meal-planning/variation-meta': {
get: op({ summary: 'List recipe variation metadata', tag: 'Meal Planning' }),
},
'/api/v1/meal-planning/variation-meta/{recipeId}': {
put: op({ summary: 'Upsert recipe variation metadata', tag: 'Meal Planning', params: [idParam('recipeId', 'Recipe ID')], stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/meal-planning/cook-assignments': {
get: op({ summary: 'List planned meal cook assignments', tag: 'Meal Planning' }),
},
'/api/v1/meal-planning/cook-assignments/{mealId}': {
put: op({ summary: 'Upsert planned meal cook assignment', tag: 'Meal Planning', params: [idParam('mealId', 'Meal ID')], stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/meal-planning/feedback': {
get: op({ summary: 'List meal planning feedback events', tag: 'Meal Planning' }),
post: op({ summary: 'Record meal planning feedback event', tag: 'Meal Planning', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/meal-planning/kids-cookbooks': {
get: op({ summary: 'List saved kids cookbooks', tag: 'Meal Planning' }),
post: op({ summary: 'Save kids cookbook', tag: 'Meal Planning', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/recipes': {
get: op({ summary: 'List recipes', tag: 'Recipes' }),
post: op({ summary: 'Create recipe', tag: 'Recipes', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/recipes/{id}': {
put: op({ summary: 'Update recipe', tag: 'Recipes', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete recipe', tag: 'Recipes', params: [idParam()], stateChanging: true }),
},
'/api/v1/calendar': {
get: op({ summary: 'List calendar events', tag: 'Calendar' }),
post: op({
summary: 'Create calendar event',
tag: 'Calendar',
stateChanging: true,
description: 'Supports optional local file attachments via `attachment_name`, `attachment_mime`, `attachment_size`, and `attachment_data` (base64 data URL).',
requestBody: jsonBody(null),
}),
},
'/api/v1/calendar/upcoming': { get: op({ summary: 'List upcoming events', tag: 'Calendar' }) },
'/api/v1/calendar/google/auth': { get: op({ summary: 'Start Google Calendar OAuth', tag: 'Calendar', admin: true }) },
'/api/v1/calendar/google/callback': { get: op({ summary: 'Google Calendar OAuth callback', tag: 'Calendar', auth: false }) },
'/api/v1/calendar/google/sync': { post: op({ summary: 'Run Google Calendar sync', tag: 'Calendar', admin: true, stateChanging: true }) },
'/api/v1/calendar/google/status': { get: op({ summary: 'Get Google Calendar status', tag: 'Calendar' }) },
'/api/v1/calendar/google/disconnect': { delete: op({ summary: 'Disconnect Google Calendar', tag: 'Calendar', admin: true, stateChanging: true }) },
'/api/v1/calendar/apple/status': { get: op({ summary: 'Get Apple Calendar status', tag: 'Calendar' }) },
'/api/v1/calendar/apple/sync': { post: op({ summary: 'Run Apple Calendar sync', tag: 'Calendar', admin: true, stateChanging: true }) },
'/api/v1/calendar/apple/connect': { post: op({ summary: 'Connect Apple Calendar', tag: 'Calendar', admin: true, stateChanging: true, requestBody: jsonBody(null) }) },
'/api/v1/calendar/apple/disconnect': { delete: op({ summary: 'Disconnect Apple Calendar', tag: 'Calendar', admin: true, stateChanging: true }) },
'/api/v1/calendar/subscriptions': {
get: op({ summary: 'List ICS subscriptions', tag: 'Calendar' }),
post: op({ summary: 'Create ICS subscription', tag: 'Calendar', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/calendar/subscriptions/{id}': {
patch: op({ summary: 'Update ICS subscription', tag: 'Calendar', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete ICS subscription', tag: 'Calendar', params: [idParam()], stateChanging: true }),
},
'/api/v1/calendar/subscriptions/{id}/sync': {
post: op({ summary: 'Sync ICS subscription', tag: 'Calendar', params: [idParam()], stateChanging: true }),
},
'/api/v1/calendar/{id}': {
get: op({ summary: 'Get calendar event', tag: 'Calendar', params: [idParam()] }),
put: op({
summary: 'Update calendar event',
tag: 'Calendar',
params: [idParam()],
stateChanging: true,
description: 'Supports optional local file attachments via `attachment_name`, `attachment_mime`, `attachment_size`, and `attachment_data` (base64 data URL).',
requestBody: jsonBody(null),
}),
delete: op({ summary: 'Delete calendar event', tag: 'Calendar', params: [idParam()], stateChanging: true }),
},
'/api/v1/calendar/{id}/reset': {
post: op({ summary: 'Reset external calendar event to source state', tag: 'Calendar', params: [idParam()], stateChanging: true }),
},
'/api/v1/notes': {
get: op({ summary: 'List notes', tag: 'Notes' }),
post: op({ summary: 'Create note', tag: 'Notes', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/notes/{id}': {
put: op({ summary: 'Update note', tag: 'Notes', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete note', tag: 'Notes', params: [idParam()], stateChanging: true }),
},
'/api/v1/notes/{id}/pin': {
patch: op({ summary: 'Toggle note pin state', tag: 'Notes', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/contacts': {
get: op({ summary: 'List contacts', tag: 'Contacts' }),
post: op({ summary: 'Create contact with multi-value fields', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/contacts/meta': { get: op({ summary: 'Get contact metadata', tag: 'Contacts' }) },
'/api/v1/contacts/cardav/accounts': {
get: op({ summary: 'List CardDAV accounts', tag: 'Contacts' }),
post: op({ summary: 'Add CardDAV account', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/contacts/cardav/accounts/{id}': {
delete: op({ summary: 'Delete CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
'/api/v1/contacts/cardav/accounts/{id}/test': {
post: op({ summary: 'Test CardDAV connection', tag: 'Contacts', params: [idParam()] }),
},
'/api/v1/contacts/cardav/accounts/{id}/addressbooks': {
get: op({ summary: 'List addressbooks for account', tag: 'Contacts', params: [idParam()] }),
},
'/api/v1/contacts/cardav/accounts/{id}/addressbooks/refresh': {
post: op({ summary: 'Refresh addressbooks for account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
'/api/v1/contacts/cardav/addressbooks/{id}': {
put: op({ summary: 'Toggle addressbook enabled state', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/contacts/cardav/accounts/{id}/sync': {
post: op({ summary: 'Sync CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
'/api/v1/contacts/{id}': {
get: op({ summary: 'Get contact with multi-value fields', tag: 'Contacts', params: [idParam()] }),
put: op({ summary: 'Update contact with multi-value fields', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }),
},
'/api/v1/contacts/{id}/vcard': { get: op({ summary: 'Download contact as vCard', tag: 'Contacts', params: [idParam()] }) },
'/api/v1/birthdays': {
get: op({ summary: 'List birthdays', tag: 'Birthdays' }),
post: op({ summary: 'Create birthday', tag: 'Birthdays', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/birthdays/upcoming': {
get: op({ summary: 'List upcoming birthdays', tag: 'Birthdays' }),
},
'/api/v1/birthdays/meta/options': {
get: op({ summary: 'Get birthday upload options', tag: 'Birthdays' }),
},
'/api/v1/birthdays/{id}': {
put: op({ summary: 'Update birthday', tag: 'Birthdays', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete birthday', tag: 'Birthdays', params: [idParam()], stateChanging: true }),
},
'/api/v1/budget/summary': { get: op({ summary: 'Get budget summary', tag: 'Budget' }) },
'/api/v1/budget/export': { get: op({ summary: 'Export budget entries as CSV', tag: 'Budget' }) },
'/api/v1/budget/meta': { get: op({ summary: 'Get budget categories and subcategories', tag: 'Budget' }) },
'/api/v1/budget/categories': {
get: op({ summary: 'List budget categories', tag: 'Budget', params: [langParam()] }),
post: op({ summary: 'Create budget category', tag: 'Budget', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/budget/categories/{categoryKey}/subcategories': {
get: op({ summary: 'List subcategories for a budget category', tag: 'Budget', params: [{ name: 'categoryKey', in: 'path', required: true, schema: { type: 'string' } }, langParam()] }),
post: op({ summary: 'Create budget subcategory', tag: 'Budget', params: [{ name: 'categoryKey', in: 'path', required: true, schema: { type: 'string' } }], stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/budget': {
get: op({ summary: 'List budget entries', tag: 'Budget' }),
post: op({ summary: 'Create budget entry', tag: 'Budget', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/budget/{id}': {
put: op({ summary: 'Update budget entry', tag: 'Budget', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete budget entry', tag: 'Budget', params: [idParam()], stateChanging: true }),
},
'/api/v1/documents/meta/options': {
get: op({
summary: 'Get family document options',
tag: 'Documents',
description: 'Returns supported categories, visibility modes, statuses, storage providers, file size limit and MIME types.',
}),
},
'/api/v1/documents': {
get: op({
summary: 'List family documents',
tag: 'Documents',
params: [
{
name: 'status',
in: 'query',
required: false,
schema: { type: 'string', enum: ['active', 'archived'], default: 'active' },
},
{
name: 'category',
in: 'query',
required: false,
schema: {
type: 'string',
enum: ['medical', 'school', 'identity', 'insurance', 'finance', 'home', 'vehicle', 'legal', 'travel', 'pets', 'warranty', 'taxes', 'work', 'other'],
},
},
],
}),
post: op({
summary: 'Upload family document',
tag: 'Documents',
stateChanging: true,
description: 'Stores a local document with family, restricted, or private visibility. File content is sent as a base64 data URL in `content_data`.',
requestBody: jsonBody(null),
responses: {
201: { description: 'Document created' },
400: { $ref: '#/components/responses/BadRequest' },
401: { $ref: '#/components/responses/Unauthorized' },
500: { $ref: '#/components/responses/InternalServerError' },
},
}),
},
'/api/v1/documents/{id}': {
put: op({
summary: 'Update family document metadata',
tag: 'Documents',
params: [idParam()],
stateChanging: true,
description: 'Updates name, description, category, status, visibility and allowed member IDs. Only the owner or an admin can update a document.',
requestBody: jsonBody(null),
}),
delete: op({
summary: 'Delete family document',
tag: 'Documents',
params: [idParam()],
stateChanging: true,
description: 'Deletes a document. Only the owner or an admin can delete it.',
responses: {
204: { description: 'Document deleted' },
401: { $ref: '#/components/responses/Unauthorized' },
403: { $ref: '#/components/responses/Forbidden' },
404: { description: 'Document not found' },
500: { $ref: '#/components/responses/InternalServerError' },
},
}),
},
'/api/v1/documents/{id}/archive': {
patch: op({
summary: 'Archive or restore family document',
tag: 'Documents',
params: [idParam()],
stateChanging: true,
description: 'Archives the document by default. Send `{ "archived": false }` to restore it to active status.',
requestBody: jsonBody(null),
}),
},
'/api/v1/documents/{id}/download': {
get: op({
summary: 'Download family document file',
tag: 'Documents',
params: [idParam()],
responses: {
200: {
description: 'Document file bytes',
content: {
'application/octet-stream': {
schema: { type: 'string', format: 'binary' },
},
},
},
401: { $ref: '#/components/responses/Unauthorized' },
404: { description: 'Document not found' },
500: { $ref: '#/components/responses/InternalServerError' },
},
}),
},
'/api/v1/weather': { get: op({ summary: 'Get weather data', tag: 'Weather' }) },
'/api/v1/weather/icon/{code}': {
get: op({ summary: 'Get weather icon asset', tag: 'Weather', params: [{ name: 'code', in: 'path', required: true, schema: { type: 'string' } }] }),
},
'/api/v1/preferences': {
get: op({ summary: 'Get user preferences', tag: 'Preferences' }),
put: op({ summary: 'Update user preferences', tag: 'Preferences', stateChanging: true, requestBody: jsonBody(null) }),
},
'/api/v1/reminders/pending': { get: op({ summary: 'List pending reminders', tag: 'Reminders' }) },
'/api/v1/reminders': {
get: op({ summary: 'List reminders', tag: 'Reminders' }),
post: op({ summary: 'Create reminder', tag: 'Reminders', stateChanging: true, requestBody: jsonBody(null) }),
delete: op({ summary: 'Delete reminders by filter', tag: 'Reminders', stateChanging: true }),
},
'/api/v1/reminders/{id}/dismiss': {
patch: op({ summary: 'Dismiss reminder', tag: 'Reminders', params: [idParam()], stateChanging: true }),
},
'/api/v1/reminders/{id}': {
delete: op({ summary: 'Delete reminder', tag: 'Reminders', params: [idParam()], stateChanging: true }),
},
'/api/v1/search': { get: op({ summary: 'Search across modules', tag: 'Search' }) },
};
}
function buildOpenApiSpec(req, appVersion) {
const origin = `${req.protocol}://${req.get('host')}`;
return {
openapi: '3.1.0',
info: {
title: 'Oikos API',
version: appVersion,
description: 'OpenAPI documentation for the Oikos family organizer backend.',
},
servers: [
{ url: origin, description: 'Current server' },
],
tags: [
{ name: 'System' },
{ name: 'Auth' },
{ name: 'Family' },
{ name: 'Dashboard' },
{ name: 'Tasks' },
{ name: 'Shopping' },
{ name: 'Meals' },
{ name: 'Meal Planning' },
{ name: 'Recipes' },
{ name: 'Calendar' },
{ name: 'Notes' },
{ name: 'Contacts' },
{ name: 'Birthdays' },
{ name: 'Budget' },
{ name: 'Documents' },
{ name: 'Backup' },
{ name: 'Weather' },
{ name: 'Preferences' },
{ name: 'Reminders' },
{ name: 'Search' },
],
paths: buildPaths(),
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
description: 'API token sent in the Authorization header as `Bearer <token>`.',
},
apiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
description: 'API token sent in the `X-API-Key` header.',
},
cookieAuth: {
type: 'apiKey',
in: 'cookie',
name: 'oikos.sid',
description: 'Browser session cookie. State-changing requests also require `X-CSRF-Token`.',
},
},
responses: {
BadRequest: {
description: 'Bad request',
content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
},
Unauthorized: {
description: 'Authentication required or invalid credentials/token',
content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
},
Forbidden: {
description: 'Permission denied',
content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
},
InternalServerError: {
description: 'Internal server error',
content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
},
},
schemas: {
ApiError: {
type: 'object',
properties: {
error: { type: 'string' },
code: { type: 'integer' },
},
},
HealthResponse: {
type: 'object',
properties: {
status: { type: 'string', example: 'ok' },
timestamp: { type: 'string', format: 'date-time' },
},
required: ['status', 'timestamp'],
},
VersionResponse: {
type: 'object',
properties: {
version: { type: 'string' },
},
required: ['version'],
},
User: {
type: 'object',
properties: {
id: { type: 'integer' },
username: { type: 'string' },
display_name: { type: 'string' },
avatar_color: { type: 'string' },
avatar_data: { type: ['string', 'null'], description: 'PNG, JPEG, or WebP data URL.' },
role: { type: 'string', enum: ['admin', 'member'] },
family_role: { type: 'string', enum: ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'] },
phone: { type: ['string', 'null'] },
email: { type: ['string', 'null'] },
birth_date: { type: ['string', 'null'], format: 'date' },
},
required: ['id', 'username', 'display_name', 'avatar_color', 'role', 'family_role'],
},
FamilyMember: {
type: 'object',
properties: {
id: { type: 'integer' },
display_name: { type: 'string' },
avatar_color: { type: 'string' },
avatar_data: { type: ['string', 'null'], description: 'PNG, JPEG, or WebP data URL.' },
family_role: { type: 'string', enum: ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'] },
phone: { type: ['string', 'null'] },
email: { type: ['string', 'null'] },
birth_date: { type: ['string', 'null'], format: 'date' },
created_at: { type: 'string', format: 'date-time' },
},
required: ['id', 'display_name', 'avatar_color', 'family_role'],
},
FamilyMembersResponse: {
type: 'object',
properties: {
data: {
type: 'array',
items: { $ref: '#/components/schemas/FamilyMember' },
},
},
required: ['data'],
},
LoginRequest: {
type: 'object',
properties: {
username: { type: 'string' },
password: { type: 'string' },
},
required: ['username', 'password'],
},
LoginResponse: {
type: 'object',
properties: {
user: { $ref: '#/components/schemas/User' },
csrfToken: { type: 'string' },
},
required: ['user', 'csrfToken'],
},
MeResponse: {
type: 'object',
properties: {
user: { $ref: '#/components/schemas/User' },
csrfToken: { type: 'string' },
},
required: ['user'],
},
SetupRequest: {
type: 'object',
properties: {
username: { type: 'string' },
display_name: { type: 'string' },
password: { type: 'string' },
},
required: ['username', 'display_name', 'password'],
},
PasswordChangeRequest: {
type: 'object',
properties: {
currentPassword: { type: 'string' },
newPassword: { type: 'string' },
},
required: ['currentPassword', 'newPassword'],
},
UserCreateRequest: {
type: 'object',
properties: {
username: { type: 'string' },
display_name: { type: 'string' },
password: { type: 'string' },
avatar_color: { type: 'string' },
avatar_data: { type: ['string', 'null'], description: 'PNG, JPEG, or WebP data URL.' },
family_role: { type: 'string', enum: ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'] },
system_admin: { type: 'boolean' },
phone: { type: ['string', 'null'] },
email: { type: ['string', 'null'] },
birth_date: { type: ['string', 'null'], format: 'date' },
},
required: ['username', 'display_name', 'password'],
},
UserUpdateRequest: {
type: 'object',
properties: {
username: { type: 'string' },
display_name: { type: 'string' },
avatar_color: { type: 'string' },
avatar_data: { type: ['string', 'null'], description: 'PNG, JPEG, or WebP data URL. Use null to remove.' },
family_role: { type: 'string', enum: ['dad', 'mom', 'parent', 'child', 'grandparent', 'relative', 'other'] },
system_admin: { type: 'boolean' },
phone: { type: ['string', 'null'] },
email: { type: ['string', 'null'] },
birth_date: { type: ['string', 'null'], format: 'date' },
},
},
ProfileUpdateRequest: {
type: 'object',
properties: {
display_name: { type: 'string' },
avatar_color: { type: 'string' },
avatar_data: { type: ['string', 'null'], description: 'PNG, JPEG, or WebP data URL. Use null to remove.' },
},
},
ApiToken: {
type: 'object',
properties: {
id: { type: 'integer' },
name: { type: 'string' },
token_prefix: { type: 'string' },
created_by: { type: 'integer' },
creator_name: { type: 'string' },
expires_at: { type: ['string', 'null'], format: 'date-time' },
revoked_at: { type: ['string', 'null'], format: 'date-time' },
last_used_at: { type: ['string', 'null'], format: 'date-time' },
created_at: { type: 'string', format: 'date-time' },
},
required: ['id', 'name', 'token_prefix', 'created_by', 'created_at'],
},
ApiTokenCreateRequest: {
type: 'object',
properties: {
name: { type: 'string' },
expires_at: { type: ['string', 'null'], format: 'date-time' },
},
required: ['name'],
},
ApiTokenCreateResponse: {
type: 'object',
properties: {
data: { $ref: '#/components/schemas/ApiToken' },
token: { type: 'string' },
},
required: ['data', 'token'],
},
},
},
};
}
export { buildOpenApiSpec };