import fs from 'node:fs'; const ref = (name) => ({ $ref: `#/components/schemas/${name}` }); const id = { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' }, }; const responses = (schema) => Object.fromEntries( ['200', '401', '403', '404', '409', '422', '429'].map((status) => [ status, { description: { 200: 'Private response; Cache-Control private, no-store', 401: 'Authentication required', 403: 'Account, ownership or current reviewer/MFA policy rejected', 404: 'Module disabled or resource unavailable', 409: 'Stale version or invalid transition', 422: 'Validation error', 429: 'Rate limited', }[status], ...(status === '200' ? { content: { 'application/json': { schema } } } : {}), }, ]), ); const operation = (summary, response, body, parameters = []) => ({ summary, parameters, security: [{ session: [] }], responses: responses(response), ...(body ? { requestBody: { required: true, content: { 'application/json': { schema: body } }, }, description: 'Session authentication and Laravel CSRF/origin protection apply. Use X-XSRF-TOKEN for browser clients.', } : {}), }); const text = { type: 'string' }; const payload = { type: 'object', additionalProperties: false, required: ['title'], properties: { title: { type: 'string', maxLength: 200 }, description: { type: ['string', 'null'], maxLength: 10000 }, declared_reason: { type: ['string', 'null'], maxLength: 2000 }, source_reference: { type: ['string', 'null'], maxLength: 2000 }, valid_until: { type: ['string', 'null'], format: 'date' }, }, }; const input = { type: 'object', required: ['modality', 'classification', 'reason', 'payload'], properties: { modality: text, classification: { enum: ['internal', 'confidential', 'restricted'] }, reason: { type: 'string', minLength: 10, maxLength: 1000 }, payload, }, }; const document = { openapi: '3.1.0', info: { title: 'InvestMoz — private existing-business intake', version: '1.0.0', description: 'Internal preparation and review only. No publication, certification, legal transfer, NDA, payment or external CRM operation.', }, servers: [{ url: '/api/v1/existing-business' }], paths: { '/listings': { get: operation( 'List own active records, 20 per page', { type: 'object', required: ['records', 'management', 'modalities'], properties: { records: { type: 'object', properties: { data: { type: 'array', items: ref('Business') }, total: { type: 'integer' }, }, }, management: { type: 'boolean' }, modalities: { type: 'array', items: text }, }, }, null, [ { name: 'page', in: 'query', schema: { type: 'integer', minimum: 1 }, }, ], ), post: operation('Create private draft', ref('Business'), input), }, '/reviews': { get: operation( 'List submitted records for designated reviewers with MFA; excludes own records', { type: 'object', properties: { records: { type: 'object', properties: { data: { type: 'array', items: ref('Business') }, }, }, management: { type: 'boolean' }, }, }, ), }, '/listings/{id}': { get: operation( 'Read own record or currently reviewable submission', { type: 'object', properties: { record: ref('Business'), canEdit: { type: 'boolean' }, owner: { type: 'boolean' }, canReview: { type: 'boolean' }, history: { type: 'array', items: { type: 'object', properties: { version_no: { type: 'integer' }, action: text, created_at: text, note: text, }, }, }, }, }, null, [id], ), put: operation( 'Update owned draft or request for information; optimistic version required', ref('Business'), { ...input, required: [...input.required, 'version_no'], properties: { ...input.properties, version_no: { type: 'integer', minimum: 1 }, }, }, [id], ), }, '/listings/{id}/transitions': { post: operation( 'Submit, begin review, request information or withdraw', ref('Business'), { type: 'object', required: ['version_no', 'action', 'note'], properties: { version_no: { type: 'integer', minimum: 1 }, action: { enum: [ 'submitted', 'in_review', 'needs_information', 'withdrawn', ], }, note: { type: 'string', minLength: 10, maxLength: 2000, }, review_consent: { type: 'boolean', description: 'Must be true for submission. Stored in encrypted review evidence. Does not authorize publication.', }, }, }, [id], ), }, }, components: { securitySchemes: { session: { type: 'apiKey', in: 'cookie', name: 'session_cookie', description: 'Actual Laravel cookie name comes from deployment configuration; no bearer token support.', }, }, schemas: { Business: { type: 'object', required: [ 'id', 'code', 'modality', 'status', 'classification', 'version_no', 'payload', ], properties: { id: { type: 'string', format: 'uuid' }, code: text, modality: text, status: { enum: [ 'draft', 'submitted', 'in_review', 'needs_information', 'withdrawn', ], }, classification: { enum: ['internal', 'confidential', 'restricted'], }, version_no: { type: 'integer', minimum: 1 }, payload, }, }, }, }, }; document.paths['/listings'].post.responses['201'] = document.paths['/listings'].post.responses['200']; delete document.paths['/listings'].post.responses['200']; const objectPayload = { type: 'object', additionalProperties: false, required: ['label', 'source_reference'], properties: Object.fromEntries( [ 'label', 'declared_holder', 'document_reference', 'source_reference', 'competent_entity', 'purpose', 'location_description', 'boundaries_description', 'occupancy', 'condition', 'encumbrances', 'restrictions', 'required_authorizations', 'transfer_condition', 'contract_type', 'valid_from', 'valid_until', 'area', 'area_unit', 'amount', 'currency', 'value_kind', 'participation_percent', ].map((key) => [key, { type: ['string', 'null'] }]), ), }; const objectInput = { type: 'object', required: ['kind', 'version_no', 'reason', 'payload'], properties: { kind: { enum: [ 'company', 'participation', 'asset', 'property', 'unit', 'improvement', 'agricultural_investment', 'land_right', 'contract', 'license', 'authorization', 'encumbrance', 'valuation', 'capital_request', ], }, version_no: { type: 'integer', minimum: 1 }, reason: { type: 'string', minLength: 10, maxLength: 1000 }, payload: objectPayload, }, }; const objectResponse = { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, version_no: { type: 'integer' }, verification_status: { const: 'pending' }, }, }; document.paths['/listings/{id}/objects'] = { post: operation( 'Declare a separate restricted object; increments aggregate version without granting verification', objectResponse, objectInput, [id], ), }; document.paths['/listings/{id}/objects'].post.responses['201'] = document.paths['/listings/{id}/objects'].post.responses['200']; delete document.paths['/listings/{id}/objects'].post.responses['200']; document.paths['/listings/{id}/objects/{object}'] = { put: operation( 'Update scoped object with immutable kind; amount requires currency and value kind; area requires unit', objectResponse, objectInput, [id, { ...id, name: 'object' }], ), }; const verification = { type: 'object', required: ['version_no', 'check', 'outcome', 'evidence_reference', 'note'], properties: { version_no: { type: 'integer', minimum: 1 }, object_id: { type: ['string', 'null'], format: 'uuid' }, check: { enum: [ 'identity', 'representation', 'declared_ownership', 'documentary_ownership', 'document_received', 'document_validated', 'independent_valuation', 'land_right_documented', 'license_confirmed', 'validity', 'legal_transfer', 'prior_authorization', ], }, outcome: { enum: ['confirmed', 'rejected', 'inconclusive', 'revoked'] }, evidence_reference: { type: 'string', minLength: 10, maxLength: 2000 }, note: { type: 'string', minLength: 10, maxLength: 2000 }, valid_until: { type: ['string', 'null'], format: 'date', description: 'Required for confirmed findings; must be today or later.', }, }, }; document.paths['/listings/{id}/verifications'] = { post: operation( 'Record independent finding while in_review; immutable encrypted evidence, optimistic version and scoped object. Does not publish or certify the business.', ref('Business'), verification, [id], ), }; document.paths['/listings/{id}'].get.responses['200'].content[ 'application/json' ].schema.properties.verifications = { type: 'array', items: { ...verification, properties: { ...verification.properties, effective_outcome: { enum: [ 'confirmed', 'rejected', 'inconclusive', 'revoked', 'stale', 'expired', ], }, content_version: { type: 'integer' }, created_at: text, }, }, }; const formSchema = JSON.parse( fs.readFileSync( 'docs/investmoz/existing-business/form-schema.json', 'utf8', ), ); for (const [key, field] of Object.entries(formSchema)) { payload.properties[key] = { type: key === 'title' ? 'string' : ['string', 'null'], maxLength: field.max, ...(field.options ? { enum: [...Object.keys(field.options), null] } : {}), ...(field.type === 'date' ? { format: 'date' } : {}), description: field.en, }; } payload.properties.wizard_version = { type: ['string', 'null'], enum: ['1', '2', null], }; payload.properties.wizard_step = { type: ['string', 'null'], pattern: '^[1-8]$', }; input.properties.sector_id = { type: ['integer', 'null'], description: 'Existing active public.sectors identifier; required on wizard submission.', }; document.paths['/listings/{id}'].put.requestBody.content[ 'application/json' ].schema.properties.sector_id = input.properties.sector_id; document.components.schemas.Business.properties.sector_id = input.properties.sector_id; document.paths['/listings/{id}/transitions'].post.requestBody.content[ 'application/json' ].schema.properties.confirmation_code = { type: ['string', 'null'], description: 'Exact business code required for wizard_version=1 submission, in addition to internal review consent.', }; document.info.version = '1.2.0'; fs.writeFileSync( 'docs/investmoz/existing-business/openapi.json', JSON.stringify(document, null, 2) + '\n', ); console.log('OpenAPI written: 9 operations, private session API.');