shutro-erp 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/README.md +17 -10
  2. package/dist/account_portal/.kosh/bindings.d.ts +22 -0
  3. package/dist/account_portal/.kosh/bindings.js +15 -0
  4. package/dist/account_portal/.kosh/schema.d.ts +1350 -0
  5. package/dist/account_portal/index.d.ts +3 -0
  6. package/dist/account_portal/index.js +73 -0
  7. package/dist/account_portal/module.d.ts +5983 -0
  8. package/dist/account_portal/module.js +18 -0
  9. package/dist/accounting/.kosh/bindings.js +1 -1
  10. package/dist/accounting/.kosh/schema.d.ts +142 -9
  11. package/dist/accounting/chart.d.ts +40 -8
  12. package/dist/accounting/chart.js +124 -30
  13. package/dist/accounting/closing.d.ts +6 -1
  14. package/dist/accounting/closing.js +5 -1
  15. package/dist/accounting/demo.js +6 -5
  16. package/dist/accounting/index.d.ts +3 -3
  17. package/dist/accounting/index.js +200 -23
  18. package/dist/accounting/module.d.ts +3326 -1086
  19. package/dist/accounting/module.js +408 -66
  20. package/dist/accounting/payments.d.ts +25 -0
  21. package/dist/accounting/payments.js +96 -4
  22. package/dist/accounting/web/RegisterPayment.d.ts +3 -2
  23. package/dist/accounting/web/RegisterPayment.js +39 -6
  24. package/dist/accounting/web/SettingsPage.js +5 -1
  25. package/dist/accounting/web/messages.js +10 -0
  26. package/dist/cli/index.d.ts +1 -1
  27. package/dist/cli/index.js +1 -1
  28. package/dist/cli/init.d.ts +5 -0
  29. package/dist/cli/init.js +195 -53
  30. package/dist/cli/launch.d.ts +16 -0
  31. package/dist/cli/launch.js +138 -0
  32. package/dist/cli/prompt.d.ts +35 -0
  33. package/dist/cli/prompt.js +46 -0
  34. package/dist/cli/templates.d.ts +11 -3
  35. package/dist/cli/templates.js +32 -7
  36. package/dist/cli/tui.d.ts +7 -0
  37. package/dist/cli/tui.js +254 -0
  38. package/dist/cli/ui.d.ts +25 -5
  39. package/dist/cli/ui.js +49 -9
  40. package/dist/contacts/.kosh/bindings.js +1 -1
  41. package/dist/contacts/.kosh/schema.d.ts +3 -1
  42. package/dist/contacts/index.js +24 -1
  43. package/dist/contacts/module.d.ts +42 -0
  44. package/dist/contacts/module.js +3 -1
  45. package/dist/import/convert.d.ts +55 -0
  46. package/dist/import/convert.js +224 -0
  47. package/dist/import/csv.d.ts +28 -0
  48. package/dist/import/csv.js +115 -0
  49. package/dist/import/index.d.ts +33 -0
  50. package/dist/import/index.js +60 -0
  51. package/dist/import/mapping.d.ts +15 -0
  52. package/dist/import/mapping.js +33 -0
  53. package/dist/import/match.d.ts +24 -0
  54. package/dist/import/match.js +60 -0
  55. package/dist/import/models.d.ts +2 -0
  56. package/dist/import/models.js +102 -0
  57. package/dist/import/routes.d.ts +13 -0
  58. package/dist/import/routes.js +392 -0
  59. package/dist/import/run.d.ts +131 -0
  60. package/dist/import/run.js +568 -0
  61. package/dist/import/service.d.ts +153 -0
  62. package/dist/import/service.js +367 -0
  63. package/dist/import/web/ImportButton.d.ts +7 -0
  64. package/dist/import/web/ImportButton.js +31 -0
  65. package/dist/import/web/ImportWizard.d.ts +75 -0
  66. package/dist/import/web/ImportWizard.js +217 -0
  67. package/dist/import/web/index.d.ts +13 -0
  68. package/dist/import/web/index.js +18 -0
  69. package/dist/import/web/messages.d.ts +2 -0
  70. package/dist/import/web/messages.js +139 -0
  71. package/dist/import/worker.d.ts +3 -0
  72. package/dist/import/worker.js +52 -0
  73. package/dist/import/xlsx.d.ts +20 -0
  74. package/dist/import/xlsx.js +192 -0
  75. package/dist/l10n_bd/.kosh/bindings.d.ts +22 -0
  76. package/dist/l10n_bd/.kosh/bindings.js +15 -0
  77. package/dist/l10n_bd/.kosh/schema.d.ts +1281 -0
  78. package/dist/l10n_bd/documents.d.ts +8 -0
  79. package/dist/l10n_bd/documents.js +183 -0
  80. package/dist/l10n_bd/index.d.ts +8 -0
  81. package/dist/l10n_bd/index.js +156 -0
  82. package/dist/l10n_bd/module.d.ts +5705 -0
  83. package/dist/l10n_bd/module.js +163 -0
  84. package/dist/l10n_bd/mushak.d.ts +67 -0
  85. package/dist/l10n_bd/mushak.js +139 -0
  86. package/dist/l10n_bd/routes.d.ts +3 -0
  87. package/dist/l10n_bd/routes.js +205 -0
  88. package/dist/l10n_bd/web/MushakPage.d.ts +7 -0
  89. package/dist/l10n_bd/web/MushakPage.js +53 -0
  90. package/dist/l10n_bd/web/index.d.ts +13 -0
  91. package/dist/l10n_bd/web/index.js +18 -0
  92. package/dist/l10n_bd/web/messages.d.ts +2 -0
  93. package/dist/l10n_bd/web/messages.js +101 -0
  94. package/dist/l10n_in/.kosh/bindings.d.ts +22 -0
  95. package/dist/l10n_in/.kosh/bindings.js +15 -0
  96. package/dist/l10n_in/.kosh/schema.d.ts +1343 -0
  97. package/dist/l10n_in/documents.d.ts +8 -0
  98. package/dist/l10n_in/documents.js +114 -0
  99. package/dist/l10n_in/einvoice.d.ts +253 -0
  100. package/dist/l10n_in/einvoice.js +485 -0
  101. package/dist/l10n_in/gstr.d.ts +109 -0
  102. package/dist/l10n_in/gstr.js +430 -0
  103. package/dist/l10n_in/index.d.ts +20 -0
  104. package/dist/l10n_in/index.js +232 -0
  105. package/dist/l10n_in/module.d.ts +7823 -0
  106. package/dist/l10n_in/module.js +427 -0
  107. package/dist/l10n_in/routes-einvoice.d.ts +15 -0
  108. package/dist/l10n_in/routes-einvoice.js +124 -0
  109. package/dist/l10n_in/routes.d.ts +17 -0
  110. package/dist/l10n_in/routes.js +282 -0
  111. package/dist/l10n_in/web/EInvoicePanel.d.ts +8 -0
  112. package/dist/l10n_in/web/EInvoicePanel.js +63 -0
  113. package/dist/l10n_in/web/GstPage.d.ts +7 -0
  114. package/dist/l10n_in/web/GstPage.js +57 -0
  115. package/dist/l10n_in/web/GstSettings.d.ts +10 -0
  116. package/dist/l10n_in/web/GstSettings.js +36 -0
  117. package/dist/l10n_in/web/index.d.ts +15 -0
  118. package/dist/l10n_in/web/index.js +22 -0
  119. package/dist/l10n_in/web/messages.d.ts +2 -0
  120. package/dist/l10n_in/web/messages.js +166 -0
  121. package/dist/modules-sdk/demo.d.ts +2 -0
  122. package/dist/modules-sdk/demo.js +7 -3
  123. package/dist/modules-sdk/index.d.ts +2 -2
  124. package/dist/modules-sdk/index.js +1 -1
  125. package/dist/modules-sdk/manifest.d.ts +132 -0
  126. package/dist/modules-sdk/manifest.js +22 -0
  127. package/dist/notify/inbox.js +21 -0
  128. package/dist/notify/messages.js +4 -0
  129. package/dist/notify/models.d.ts +1 -1
  130. package/dist/notify/models.js +2 -0
  131. package/dist/notify/web/messages.js +4 -0
  132. package/dist/portal/.kosh/bindings.d.ts +22 -0
  133. package/dist/portal/.kosh/bindings.js +15 -0
  134. package/dist/portal/.kosh/schema.d.ts +346 -0
  135. package/dist/portal/announce.d.ts +16 -0
  136. package/dist/portal/announce.js +117 -0
  137. package/dist/portal/documents.d.ts +17 -0
  138. package/dist/portal/documents.js +61 -0
  139. package/dist/portal/index.d.ts +20 -0
  140. package/dist/portal/index.js +65 -0
  141. package/dist/portal/module.d.ts +1405 -0
  142. package/dist/portal/module.js +84 -0
  143. package/dist/portal/routes-documents.d.ts +2 -0
  144. package/dist/portal/routes-documents.js +391 -0
  145. package/dist/portal/routes.d.ts +13 -0
  146. package/dist/portal/routes.js +313 -0
  147. package/dist/portal/rules.d.ts +11 -0
  148. package/dist/portal/rules.js +25 -0
  149. package/dist/portal/web/PortalAccess.d.ts +8 -0
  150. package/dist/portal/web/PortalAccess.js +66 -0
  151. package/dist/portal/web/index.d.ts +12 -0
  152. package/dist/portal/web/index.js +17 -0
  153. package/dist/portal/web/messages.d.ts +2 -0
  154. package/dist/portal/web/messages.js +33 -0
  155. package/dist/product/.kosh/bindings.js +1 -1
  156. package/dist/product/.kosh/schema.d.ts +12 -3
  157. package/dist/product/demo.js +6 -5
  158. package/dist/product/index.d.ts +1 -0
  159. package/dist/product/index.js +17 -4
  160. package/dist/product/module.d.ts +346 -14
  161. package/dist/product/module.js +48 -18
  162. package/dist/product/taxes.d.ts +59 -0
  163. package/dist/product/taxes.js +61 -0
  164. package/dist/project/.kosh/bindings.js +1 -1
  165. package/dist/project/.kosh/schema.d.ts +3 -1
  166. package/dist/project/module.d.ts +20 -0
  167. package/dist/project_portal/.kosh/bindings.d.ts +22 -0
  168. package/dist/project_portal/.kosh/bindings.js +15 -0
  169. package/dist/project_portal/.kosh/schema.d.ts +671 -0
  170. package/dist/project_portal/index.d.ts +9 -0
  171. package/dist/project_portal/index.js +42 -0
  172. package/dist/project_portal/module.d.ts +2142 -0
  173. package/dist/project_portal/module.js +22 -0
  174. package/dist/purchase/.kosh/bindings.js +1 -1
  175. package/dist/purchase/.kosh/schema.d.ts +15 -4
  176. package/dist/purchase/demo.js +3 -3
  177. package/dist/purchase/index.js +1 -1
  178. package/dist/purchase/module.d.ts +232 -8
  179. package/dist/purchase/module.js +51 -9
  180. package/dist/purchase_account/.kosh/bindings.js +1 -1
  181. package/dist/purchase_account/.kosh/schema.d.ts +144 -10
  182. package/dist/purchase_account/module.d.ts +2008 -641
  183. package/dist/purchase_account/module.js +2 -2
  184. package/dist/purchase_portal/.kosh/bindings.d.ts +22 -0
  185. package/dist/purchase_portal/.kosh/bindings.js +15 -0
  186. package/dist/purchase_portal/.kosh/schema.d.ts +822 -0
  187. package/dist/purchase_portal/index.d.ts +9 -0
  188. package/dist/purchase_portal/index.js +53 -0
  189. package/dist/purchase_portal/module.d.ts +2758 -0
  190. package/dist/purchase_portal/module.js +18 -0
  191. package/dist/reporting/documents.js +20 -3
  192. package/dist/reporting/index.d.ts +1 -0
  193. package/dist/reporting/index.js +1 -0
  194. package/dist/reporting/routes-documents.js +9 -2
  195. package/dist/reporting/words.d.ts +11 -0
  196. package/dist/reporting/words.js +229 -0
  197. package/dist/sale_account/.kosh/bindings.js +1 -1
  198. package/dist/sale_account/.kosh/schema.d.ts +144 -10
  199. package/dist/sale_account/module.d.ts +2008 -641
  200. package/dist/sale_account/module.js +3 -3
  201. package/dist/sale_portal/.kosh/bindings.d.ts +22 -0
  202. package/dist/sale_portal/.kosh/bindings.js +15 -0
  203. package/dist/sale_portal/.kosh/schema.d.ts +736 -0
  204. package/dist/sale_portal/index.d.ts +9 -0
  205. package/dist/sale_portal/index.js +71 -0
  206. package/dist/sale_portal/module.d.ts +2718 -0
  207. package/dist/sale_portal/module.js +19 -0
  208. package/dist/sale_project/.kosh/bindings.js +1 -1
  209. package/dist/sale_project/.kosh/schema.d.ts +15 -4
  210. package/dist/sale_project/module.d.ts +390 -10
  211. package/dist/sales/.kosh/bindings.js +1 -1
  212. package/dist/sales/.kosh/schema.d.ts +15 -4
  213. package/dist/sales/demo.js +3 -3
  214. package/dist/sales/index.js +67 -1
  215. package/dist/sales/module.d.ts +232 -8
  216. package/dist/sales/module.js +51 -12
  217. package/dist/server/auth/base.js +2 -0
  218. package/dist/server/auth/service.d.ts +2 -0
  219. package/dist/server/auth/service.js +7 -1
  220. package/dist/server/index.d.ts +1 -1
  221. package/dist/server/index.js +1 -1
  222. package/dist/server/server.js +26 -1
  223. package/dist/ui/api.d.ts +2 -0
  224. package/dist/ui/app.js +1 -1
  225. package/dist/ui/context.d.ts +3 -1
  226. package/dist/ui/context.js +2 -2
  227. package/dist/ui/index.d.ts +1 -1
  228. package/dist/ui/index.js +1 -1
  229. package/dist/ui/lib/format.d.ts +2 -0
  230. package/dist/ui/lib/format.js +3 -1
  231. package/dist/ui/views/FormView.js +19 -6
  232. package/package.json +50 -2
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The data layer of the portal (RFC 0015 §5.1–5.2): portal users are `res.users` holding only `portal.user` (never
3
+ * `base.user`), each linked to one partner; invitations are one-time activation tokens, kept as SHA-256 digests.
4
+ */
5
+ import { contactsShutro } from '../contacts/index.js';
6
+ import { baseShutro } from '../server/index.js';
7
+ import { orm } from '../server/orm.js';
8
+ import { defineModel, defineModule, extendModel, f } from './.kosh/bindings.js';
9
+ /** The partner a portal user acts for. Internal users have none. */
10
+ export const UserPortalPartner = extendModel('res.users', {
11
+ fields: {
12
+ portalPartner: f.many2one('res.partner', { ondelete: 'set null', label: 'Portal contact' }),
13
+ },
14
+ });
15
+ /** The portal users of a contact (one, in practice): who the invitations went to. */
16
+ export const PartnerPortalUsers = extendModel('res.partner', {
17
+ fields: {
18
+ portalUsers: f.one2many('res.users', 'portalPartner', { label: 'Portal users' }),
19
+ },
20
+ });
21
+ /** A one-time link that lets an invited contact choose a password. Private: read through the portal routes. */
22
+ export const PortalInvitation = defineModel('portal.invitation', {
23
+ label: 'Portal invitation',
24
+ order: ['id desc'],
25
+ access: {},
26
+ fields: {
27
+ user: f.many2one('res.users', { required: true, ondelete: 'cascade' }),
28
+ tokenHash: f.char({ required: true, index: 'unique' }),
29
+ expiresAt: f.datetime({ required: true }),
30
+ usedAt: f.datetime(),
31
+ invitedBy: f.many2one('res.users', { ondelete: 'set null' }),
32
+ },
33
+ });
34
+ /**
35
+ * A signature given in the portal (RFC 0015 §5.3): who signed which document, under which name, the drawn image,
36
+ * when, from where, and the hash of what they saw. Private: written by the portal routes, read by staff.
37
+ */
38
+ export const PortalSignature = defineModel('portal.signature', {
39
+ label: 'Portal signature',
40
+ order: ['id desc'],
41
+ companyScoped: { required: true },
42
+ access: { 'base.user': ['read'] },
43
+ fields: {
44
+ resModel: f.char({ required: true, label: 'Model' }),
45
+ resId: f.integer({ required: true, label: 'Record' }),
46
+ action: f.char({ required: true, size: 40 }),
47
+ user: f.many2one('res.users', { required: true, ondelete: 'restrict' }),
48
+ signedName: f.char({ required: true, size: 200, label: 'Name' }),
49
+ image: f.binary({ label: 'Signature' }),
50
+ /** SHA-256 of the declared fields and lines the user saw. */
51
+ documentHash: f.char({ required: true, size: 64 }),
52
+ ip: f.char({ size: 64 }),
53
+ },
54
+ });
55
+ /** A document announced to its customer by email (RFC 0015 §5.7): once per record and document type. */
56
+ export const PortalAnnouncement = defineModel('portal.announcement', {
57
+ label: 'Portal announcement',
58
+ access: {},
59
+ fields: {
60
+ resModel: f.char({ required: true, label: 'Model' }),
61
+ resId: f.integer({ required: true, label: 'Record' }),
62
+ document: f.char({ required: true, size: 64 }),
63
+ recipients: f.integer({ required: true, default: 0 }),
64
+ },
65
+ sqlConstraints: {
66
+ once: { unique: ['resModel', 'resId', 'document'], message: 'A document is announced once.' },
67
+ },
68
+ });
69
+ export const portalShutro = defineModule({
70
+ name: 'portal',
71
+ version: '0.1.0',
72
+ depends: [baseShutro, contactsShutro],
73
+ groups: [
74
+ // Never implies base.user: holders are outside the company (the server keeps them to the portal).
75
+ orm.defineGroup({ code: 'portal.user', label: 'Portal user' }),
76
+ orm.defineGroup({
77
+ code: 'portal.manager',
78
+ label: 'Portal access manager',
79
+ implies: ['contacts.user'],
80
+ }),
81
+ ],
82
+ models: [PortalInvitation, PortalSignature, PortalAnnouncement],
83
+ extensions: [UserPortalPartner, PartnerPortalUsers],
84
+ });
@@ -0,0 +1,2 @@
1
+ import { type ModuleContext } from '../server/index.js';
2
+ export declare function registerPortalDocumentRoutes(context: ModuleContext): void;
@@ -0,0 +1,391 @@
1
+ /**
2
+ * The portal's documents API (RFC 0015 §5.3–5.5), for portal users only:
3
+ *
4
+ * - `GET /api/portal/v1/home` per document type: what needs the user, and counts
5
+ * - `GET /api/portal/v1/documents` the document types (and how many the user has)
6
+ * - `GET /api/portal/v1/documents/:key?q=&offset=&limit=` a list: the declared list fields
7
+ * - `GET /api/portal/v1/documents/:key/:id` a document: the declared fields, lines, actions
8
+ * - `GET /api/portal/v1/documents/:key/:id/pdf` its PDF, from the declared template
9
+ * - `POST /api/portal/v1/documents/:key/:id/actions/:action` `{ signature?: { name, image }, message? }`
10
+ * - `GET /api/portal/v1/documents/:key/:id/thread` its comments (never notes, activities or the log)
11
+ * - `POST /api/portal/v1/documents/:key/:id/thread` `{ body }`: a comment, its followers notified
12
+ *
13
+ * Which records: always a search **as the portal user**, so the model's portal record rule decides, narrowed by the
14
+ * declared domain; another customer's document is 404. What of them: only the declared fields, read once the record
15
+ * is known to be theirs (relations as their names). Actions run their transition in a system transaction once the
16
+ * checks passed — the system confirms on the customer's word, as an employee would — so the apps' own reactions
17
+ * (tasks of a confirmed order) run with the rights they need; the signature, the thread and the audit name the
18
+ * portal user.
19
+ */
20
+ import { createHash } from 'node:crypto';
21
+ import { chatterContext, postExternalMessage } from '../messaging/index.js';
22
+ import { documentFields, renderDocument } from '../reporting/index.js';
23
+ import { audit, HttpProblem, idempotent } from '../server/index.js';
24
+ import { portalDocuments } from './documents.js';
25
+ const ref = (env, name) => env.model(name);
26
+ /** Largest drawn signature accepted (a PNG data URL, decoded). */
27
+ const MAX_SIGNATURE_BYTES = 300_000;
28
+ const ATTENTION_ITEMS = 5;
29
+ export function registerPortalDocumentRoutes(context) {
30
+ const { app, driver } = context;
31
+ const documents = portalDocuments(context.registry);
32
+ const printable = documentFields(new Map([...documents.values()].map((d) => [d.model.name, d.model])));
33
+ const chatter = chatterContext(context);
34
+ const portalOnly = (env) => {
35
+ if (!env.groups.has('portal.user') || env.groups.has('base.user'))
36
+ throw new HttpProblem(403, 'portal-only', 'Portal users only', 'This is the portal’s API.');
37
+ };
38
+ const documentOf = (params) => {
39
+ const key = String(params['key'] ?? '');
40
+ const document = documents.get(key);
41
+ if (document === undefined)
42
+ throw new HttpProblem(404, 'not-found', 'No such document type', `No documents '${key}'.`);
43
+ return document;
44
+ };
45
+ const idOf = (params) => {
46
+ const id = Number(params['id']);
47
+ if (!Number.isSafeInteger(id) || id <= 0)
48
+ throw new HttpProblem(404, 'not-found', 'No such document', 'No such document.');
49
+ return id;
50
+ };
51
+ /** The declared records the user may see, narrowed by `extra`. */
52
+ const scope = (d, extra = {}) => ({
53
+ and: [d.spec.domain ?? {}, extra],
54
+ });
55
+ /** Whether record `id` is the user's (searched as the user: the record rule decides). */
56
+ const mine = async (env, d, id) => {
57
+ const found = await ref(env, d.model.name).search({ where: scope(d, { id }), limit: 1 });
58
+ if (found.length === 0)
59
+ throw new HttpProblem(404, 'not-found', 'No such document', `No ${d.spec.label} #${id}.`);
60
+ };
61
+ /** Field descriptions of `keys` of `model`, for the screen. */
62
+ const meta = (model, keys) => keys.map((key) => {
63
+ const f = model.byKey.get(key);
64
+ return {
65
+ key,
66
+ label: f?.label ?? key,
67
+ kind: f?.kind ?? 'char',
68
+ ...(f?.options === null || f?.options === undefined
69
+ ? {}
70
+ : { options: f.options.map(([value, label]) => ({ value, label })) }),
71
+ };
72
+ });
73
+ /** `keys` of `ids` read as the system (the records are known to be the user's): relations as names. */
74
+ const values = async (su, model, ids, keys) => {
75
+ if (ids.length === 0)
76
+ return [];
77
+ const loaded = await ref(su, model.name)
78
+ .browse(ids)
79
+ .load([...keys]);
80
+ const rows = loaded.read([...keys]);
81
+ for (const key of keys) {
82
+ const field = model.byKey.get(key);
83
+ if (field?.kind !== 'many2one' && field?.kind !== 'many2many')
84
+ continue;
85
+ const targetIds = [
86
+ ...new Set(rows.flatMap((r) => (Array.isArray(r[key]) ? r[key] : [r[key]]))),
87
+ ].filter((v) => typeof v === 'number');
88
+ const names = new Map();
89
+ if (targetIds.length > 0 && field.target !== null)
90
+ for (const t of (await ref(su, field.target).browse(targetIds).load(['displayName'])).read([
91
+ 'displayName',
92
+ ]))
93
+ names.set(t['id'], String(t['displayName'] ?? ''));
94
+ const named = (v) => typeof v === 'number' ? { id: v, name: names.get(v) ?? '' } : null;
95
+ for (const r of rows)
96
+ r[key] =
97
+ field.kind === 'many2one' ? named(r[key]) : (r[key] ?? []).map(named);
98
+ }
99
+ return rows;
100
+ };
101
+ /** The actions `when` allows on record `id`. */
102
+ const available = async (su, d, id) => {
103
+ const out = [];
104
+ for (const action of d.spec.actions ?? [])
105
+ if ((await ref(su, d.model.name).search({ where: { and: [{ id }, action.when] }, limit: 1 }))
106
+ .length > 0)
107
+ out.push({
108
+ key: action.key,
109
+ label: action.label,
110
+ signature: action.signature === true,
111
+ message: action.message === true,
112
+ });
113
+ return out;
114
+ };
115
+ /** The document as the user sees it: what a signature's hash covers. */
116
+ const documentOfRecord = async (su, d, id) => {
117
+ const [record] = await values(su, d.model, [id], d.spec.fields);
118
+ let lines = [];
119
+ if (d.spec.lines !== undefined && d.lineModel !== null) {
120
+ const [withLines] = (await ref(su, d.model.name)
121
+ .browse([id])
122
+ .load({ [d.spec.lines.field]: true })).read([d.spec.lines.field]);
123
+ const lineIds = withLines?.[d.spec.lines.field] ?? [];
124
+ lines = await values(su, d.lineModel, lineIds, d.spec.lines.fields);
125
+ }
126
+ return { record: record ?? { id }, lines };
127
+ };
128
+ const run = (request, fn) => request.env((env) => {
129
+ portalOnly(env);
130
+ return env.asSuperuser({ reason: 'portal read' }, (su) => fn(env, su));
131
+ }, { write: true });
132
+ app.get('/api/portal/v1/documents', async (request) => run(request, async (env) => {
133
+ const out = [];
134
+ for (const d of documents.values()) {
135
+ const [row] = await ref(env, d.model.name).aggregate({
136
+ where: scope(d),
137
+ measures: { count: 'count' },
138
+ });
139
+ out.push({ key: d.spec.key, label: d.spec.label, count: Number(row?.['count'] ?? 0) });
140
+ }
141
+ return { documents: out };
142
+ }));
143
+ app.get('/api/portal/v1/home', async (request) => run(request, async (env, su) => {
144
+ const sections = [];
145
+ for (const d of documents.values()) {
146
+ if (d.spec.attention === undefined)
147
+ continue;
148
+ const found = await ref(env, d.model.name).search({
149
+ where: scope(d, d.spec.attention),
150
+ ...(d.spec.order === undefined ? {} : { order: d.spec.order }),
151
+ limit: ATTENTION_ITEMS,
152
+ });
153
+ const [count] = await ref(env, d.model.name).aggregate({
154
+ where: scope(d, d.spec.attention),
155
+ measures: { count: 'count' },
156
+ });
157
+ sections.push({
158
+ key: d.spec.key,
159
+ label: d.spec.label,
160
+ count: Number(count?.['count'] ?? 0),
161
+ fields: meta(d.model, d.spec.list),
162
+ items: await values(su, d.model, found.ids, d.spec.list),
163
+ });
164
+ }
165
+ return { sections };
166
+ }));
167
+ app.get('/api/portal/v1/documents/:key', async (request) => {
168
+ const d = documentOf(request.params);
169
+ const q = (request.query ?? {});
170
+ const limit = Math.min(Math.max(Number(q['limit'] ?? 30) || 30, 1), 100);
171
+ const offset = Math.max(Number(q['offset'] ?? 0) || 0, 0);
172
+ const text = typeof q['q'] === 'string' ? q['q'].trim().slice(0, 100) : '';
173
+ const recName = d.model.recName ?? 'name';
174
+ const where = scope(d, text === '' ? {} : { [recName]: { ilike: `%${text.replace(/[%_\\]/g, (m) => `\\${m}`)}%` } });
175
+ return run(request, async (env, su) => {
176
+ const found = await ref(env, d.model.name).search({
177
+ where,
178
+ ...(d.spec.order === undefined ? {} : { order: d.spec.order }),
179
+ limit,
180
+ offset,
181
+ });
182
+ const [count] = await ref(env, d.model.name).aggregate({
183
+ where,
184
+ measures: { count: 'count' },
185
+ });
186
+ return {
187
+ key: d.spec.key,
188
+ label: d.spec.label,
189
+ fields: meta(d.model, d.spec.list),
190
+ items: await values(su, d.model, found.ids, d.spec.list),
191
+ count: Number(count?.['count'] ?? 0),
192
+ };
193
+ });
194
+ });
195
+ app.get('/api/portal/v1/documents/:key/:id', async (request) => {
196
+ const d = documentOf(request.params);
197
+ const id = idOf(request.params);
198
+ return run(request, async (env, su) => {
199
+ await mine(env, d, id);
200
+ const { record, lines } = await documentOfRecord(su, d, id);
201
+ return {
202
+ key: d.spec.key,
203
+ label: d.spec.label,
204
+ fields: meta(d.model, d.spec.fields),
205
+ record,
206
+ ...(d.spec.lines === undefined || d.lineModel === null
207
+ ? {}
208
+ : { lineFields: meta(d.lineModel, d.spec.lines.fields), lines }),
209
+ actions: await available(su, d, id),
210
+ pdf: d.spec.pdf !== undefined,
211
+ thread: d.spec.thread === true,
212
+ };
213
+ });
214
+ });
215
+ app.get('/api/portal/v1/documents/:key/:id/pdf', async (request, reply) => {
216
+ const d = documentOf(request.params);
217
+ const id = idOf(request.params);
218
+ const template = d.spec.pdf;
219
+ if (template === undefined)
220
+ throw new HttpProblem(404, 'not-found', 'No PDF', `${d.spec.label} have no PDF.`);
221
+ const rendered = await run(request, async (env, su) => {
222
+ await mine(env, d, id);
223
+ const keys = ['name', 'layout', 'scope'];
224
+ const found = await ref(su, 'report.template').search({
225
+ where: { key: template, targetModel: d.model.name, active: true },
226
+ limit: 1,
227
+ });
228
+ const [row] = (await found.load(keys)).read(keys);
229
+ if (row === undefined)
230
+ throw new HttpProblem(404, 'not-found', 'No PDF', `The template ${template} is not installed.`);
231
+ // The company's printed document of a record that is the user's: printed as the system.
232
+ return renderDocument(su, d.model, row, printable, { ids: [id] }, 'pdf');
233
+ });
234
+ return reply
235
+ .header('content-type', rendered.mime)
236
+ .header('content-disposition', `attachment; filename="${rendered.filename.replace(/["\\\r\n]/g, '_')}"`)
237
+ .send(rendered.content);
238
+ });
239
+ app.post('/api/portal/v1/documents/:key/:id/actions/:action', async (request, reply) => {
240
+ const d = documentOf(request.params);
241
+ const id = idOf(request.params);
242
+ const key = String(request.params['action'] ?? '');
243
+ const action = (d.spec.actions ?? []).find((a) => a.key === key);
244
+ if (action === undefined)
245
+ throw new HttpProblem(404, 'not-found', 'No such action', `${d.spec.label} have no '${key}'.`);
246
+ const body = (request.body ?? {});
247
+ const message = typeof body['message'] === 'string' ? body['message'].trim() : '';
248
+ if (action.message === true && message === '')
249
+ throw new HttpProblem(422, 'message-required', 'Say why', 'Write a message for this action.');
250
+ const signature = (body['signature'] ?? null);
251
+ const signedName = typeof signature?.['name'] === 'string' ? signature['name'].trim() : '';
252
+ let image = null;
253
+ if (action.signature === true) {
254
+ const data = typeof signature?.['image'] === 'string' ? signature['image'] : '';
255
+ const png = /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(data);
256
+ if (signedName === '' || signedName.length > 200 || png === null)
257
+ throw new HttpProblem(422, 'signature-required', 'Sign first', 'Type your name and draw your signature.');
258
+ image = new Uint8Array(Buffer.from(png[1], 'base64'));
259
+ if (image.byteLength > MAX_SIGNATURE_BYTES || image.byteLength === 0)
260
+ throw new HttpProblem(422, 'signature-too-large', 'Signature too large', 'Draw it again.');
261
+ }
262
+ const outcome = await request.env((env) => idempotent(request, env, driver, async () => {
263
+ portalOnly(env);
264
+ await mine(env, d, id);
265
+ const who = await env.asSuperuser({ reason: 'portal action' }, async (su) => {
266
+ if (!(await available(su, d, id)).some((a) => a.key === key))
267
+ throw new HttpProblem(409, 'not-available', 'Not possible any more', `${action.label} is not possible on this document now.`);
268
+ const [user] = (await ref(su, 'res.users').browse([env.uid]).load(['login', 'name'])).read(['login', 'name']);
269
+ const seen = await documentOfRecord(su, d, id);
270
+ return {
271
+ login: String(user?.['login'] ?? ''),
272
+ name: String(user?.['name'] ?? ''),
273
+ hash: createHash('sha256').update(JSON.stringify(seen)).digest('hex'),
274
+ };
275
+ });
276
+ // The transition and its record, as the system, in one transaction of their own.
277
+ await context.shutro.bootstrap({ reason: `portal ${d.spec.key} ${key} by ${who.login}`, company: env.companyId }, async (su) => {
278
+ const target = ref(su, d.model.name).browse([id]);
279
+ await target[action.transition].call(target);
280
+ let signatureId = null;
281
+ if (image !== null) {
282
+ const created = await ref(su, 'portal.signature').create([
283
+ {
284
+ resModel: d.model.name,
285
+ resId: id,
286
+ action: key,
287
+ user: env.uid,
288
+ signedName,
289
+ image,
290
+ documentHash: who.hash,
291
+ ip: request.ip,
292
+ company: env.companyId,
293
+ },
294
+ ]);
295
+ signatureId = created.ids[0] ?? null;
296
+ }
297
+ const text = [
298
+ image === null
299
+ ? `${action.label} by ${who.name} in the portal.`
300
+ : `${action.label} by ${who.name} in the portal, signed as “${signedName}”.`,
301
+ message,
302
+ ]
303
+ .filter((t) => t !== '')
304
+ .join('\n\n');
305
+ await postExternalMessage(su, chatter, d.model.name, id, {
306
+ text,
307
+ authorEmail: who.login,
308
+ authorName: who.name,
309
+ });
310
+ await audit(su, {
311
+ kind: 'portal.action',
312
+ summary: `${who.name} (${who.login}): ${action.label} on ${d.model.name} #${id} in the portal.`,
313
+ actor: env.uid,
314
+ company: env.companyId,
315
+ targetModel: d.model.name,
316
+ targetId: id,
317
+ details: { action: key, signature: signatureId, documentHash: who.hash },
318
+ ip: request.ip,
319
+ });
320
+ await su.flush(); // checks run in this scope, not at commit (Shutro request #11)
321
+ });
322
+ return { status: 200, body: { done: key } };
323
+ }), { write: true });
324
+ return reply.status(outcome.status).send(outcome.body);
325
+ });
326
+ app.get('/api/portal/v1/documents/:key/:id/thread', async (request) => {
327
+ const d = documentOf(request.params);
328
+ const id = idOf(request.params);
329
+ if (d.spec.thread !== true)
330
+ throw new HttpProblem(404, 'not-found', 'No thread', `${d.spec.label} have no thread.`);
331
+ return run(request, async (env, su) => {
332
+ await mine(env, d, id);
333
+ const keys = ['body', 'author', 'authorName', 'createdAt'];
334
+ const found = await ref(su, 'mail.message').search({
335
+ where: { resModel: d.model.name, resId: id, kind: 'comment' },
336
+ order: ['id desc'],
337
+ limit: 100,
338
+ });
339
+ const rows = (await found.load(keys)).read(keys);
340
+ const authors = [
341
+ ...new Set(rows.map((r) => r['author']).filter((a) => typeof a === 'number')),
342
+ ];
343
+ const names = new Map();
344
+ if (authors.length > 0)
345
+ for (const u of (await ref(su, 'res.users')
346
+ .browse(authors)
347
+ .load(['name'])).read(['name']))
348
+ names.set(u['id'], String(u['name']));
349
+ return {
350
+ messages: rows.map((r) => ({
351
+ id: r['id'],
352
+ at: r['createdAt'],
353
+ body: r['body'],
354
+ author: typeof r['author'] === 'number'
355
+ ? (names.get(r['author']) ?? '')
356
+ : String(r['authorName'] ?? ''),
357
+ mine: r['author'] === env.uid,
358
+ })),
359
+ };
360
+ });
361
+ });
362
+ app.post('/api/portal/v1/documents/:key/:id/thread', async (request, reply) => {
363
+ const d = documentOf(request.params);
364
+ const id = idOf(request.params);
365
+ if (d.spec.thread !== true)
366
+ throw new HttpProblem(404, 'not-found', 'No thread', `${d.spec.label} have no thread.`);
367
+ const text = String((request.body ?? {})['body'] ?? '').trim();
368
+ if (text === '')
369
+ throw new HttpProblem(400, 'invalid-body', 'Empty message', 'Write something first.');
370
+ const posted = await run(request, async (env, su) => {
371
+ await mine(env, d, id);
372
+ const [user] = (await ref(su, 'res.users').browse([env.uid]).load(['login', 'name'])).read([
373
+ 'login',
374
+ 'name',
375
+ ]);
376
+ const item = await postExternalMessage(su, chatter, d.model.name, id, {
377
+ text,
378
+ authorEmail: String(user?.['login'] ?? ''),
379
+ authorName: String(user?.['name'] ?? ''),
380
+ });
381
+ return {
382
+ id: item.id,
383
+ at: item.at,
384
+ body: item.body,
385
+ author: item.authorName ?? '',
386
+ mine: true,
387
+ };
388
+ });
389
+ return reply.code(201).send(posted);
390
+ });
391
+ }
@@ -0,0 +1,13 @@
1
+ import { type Mailer } from '../mail/index.js';
2
+ import { type ModuleContext } from '../server/index.js';
3
+ /** How long an invitation works. */
4
+ export declare const INVITATION_DAYS = 7;
5
+ /** The group of portal users. */
6
+ export declare const PORTAL_USER = "portal.user";
7
+ export interface PortalRoutesOptions {
8
+ /** Where the portal app is served: activation links point there. */
9
+ readonly portalUrl: string;
10
+ /** Sends the invitations; without one, the inviter passes the link on. */
11
+ readonly mailer?: Mailer | undefined;
12
+ }
13
+ export declare function registerPortalRoutes(context: ModuleContext, options: PortalRoutesOptions): void;