444 lines
15 KiB
JavaScript
444 lines
15 KiB
JavaScript
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.');
|