Skip to content

Commit 1d11f31

Browse files
hotlongCopilot
andcommitted
feat(platform-objects,crm): sys_attachment polymorphic link + CRM storage capability (M10.3)
Polymorphic File ↔ Record Link: - packages/platform-objects/src/audit/sys-attachment.object.ts * New SysAttachment schema (parent_object, parent_id, file_id, share_type, visibility, denormalised file_name/mime_type/size for fast list render) * Indexes: (parent_object, parent_id, created_at) for record file lists, (file_id) for reverse lookup, (uploaded_by) for user libraries * Mirrors Salesforce ContentDocumentLink / ServiceNow sys_attachment pattern - packages/plugins/plugin-audit/src/audit-plugin.ts * Registers SysAttachment alongside other sys_* objects so it lives at the same architectural tier as sys_audit_log / sys_activity / sys_comment - examples/app-crm/objectstack.config.ts * Adds 'storage' capability to requires[] so CRM auto-loads StorageServicePlugin (otherwise /api/v1/storage/* is 404) Verified end-to-end on examples/app-crm: POST /api/v1/storage/upload/presigned → fileId + uploadUrl PUT <uploadUrl> with content → 200 ok POST /api/v1/storage/upload/complete → sys_file marked committed POST /api/v1/data/sys_attachment → link row written GET /api/v1/data/sys_attachment?filter=parent_object:lead,parent_id:X → returns the linked file Validation envelope (M10.4) catches missing file_id at the create call. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6d593b0 commit 1d11f31

4 files changed

Lines changed: 159 additions & 3 deletions

File tree

examples/app-crm/objectstack.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export default defineStack({
4646
// `ui` serves the Studio shell and CRM apps under /_studio/.
4747
// Both are required for a clickable login flow when running `objectstack start`
4848
// off the compiled artifact.
49-
requires: ['ai', 'automation', 'analytics', 'auth', 'ui'],
49+
requires: ['ai', 'automation', 'analytics', 'auth', 'ui', 'storage'],
5050

5151
objects: Object.values(objects),
5252
actions: Object.values(actions),

packages/platform-objects/src/audit/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ export { SysAuditLog } from './sys-audit-log.object.js';
88
export { SysPresence } from './sys-presence.object.js';
99
export { SysActivity } from './sys-activity.object.js';
1010
export { SysComment } from './sys-comment.object.js';
11+
export { SysAttachment } from './sys-attachment.object.js';
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { ObjectSchema, Field } from '@objectstack/spec/data';
4+
5+
/**
6+
* sys_attachment — Polymorphic File ↔ Record Link
7+
*
8+
* Generic join row that attaches a previously-uploaded `sys_file` to
9+
* any other record. Mirrors the Salesforce "ContentDocumentLink" /
10+
* ServiceNow "sys_attachment" pattern: file storage and "where this
11+
* file is attached" are separated, so the same `sys_file` row can be
12+
* surfaced on multiple records, an org-wide library, or a thread.
13+
*
14+
* Conventions:
15+
* - `parent_object` is the short object name (e.g. `account`, `lead`,
16+
* `opportunity`, `case`, `sys_comment`).
17+
* - `parent_id` is the parent record's primary key.
18+
* - `(parent_object, parent_id)` is the natural index for the
19+
* "files on this record" lookup.
20+
* - `share_type` follows the Salesforce convention: V = Viewer,
21+
* C = Collaborator, I = Inferred (inherited from parent record).
22+
*
23+
* @namespace sys
24+
*/
25+
export const SysAttachment = ObjectSchema.create({
26+
name: 'sys_attachment',
27+
label: 'Attachment',
28+
pluralLabel: 'Attachments',
29+
icon: 'paperclip',
30+
isSystem: true,
31+
description: 'Polymorphic link between a sys_file and any other record',
32+
titleFormat: '{file_name} → {parent_object}/{parent_id}',
33+
compactLayout: ['created_at', 'parent_object', 'file_name', 'mime_type', 'size'],
34+
35+
fields: {
36+
id: Field.text({
37+
label: 'Attachment ID',
38+
required: true,
39+
readonly: true,
40+
group: 'System',
41+
}),
42+
43+
// ── Parent (polymorphic) ────────────────────────────────────
44+
parent_object: Field.text({
45+
label: 'Parent Object',
46+
required: true,
47+
searchable: true,
48+
maxLength: 128,
49+
description: 'Short object name of the attached-to record (e.g. account, lead)',
50+
group: 'Parent',
51+
}),
52+
53+
parent_id: Field.text({
54+
label: 'Parent Record',
55+
required: true,
56+
searchable: true,
57+
maxLength: 64,
58+
description: 'Primary key of the attached-to record',
59+
group: 'Parent',
60+
}),
61+
62+
// ── File reference ──────────────────────────────────────────
63+
file_id: Field.lookup('sys_file', {
64+
label: 'File',
65+
required: true,
66+
description: 'The sys_file storage entry being attached',
67+
group: 'File',
68+
}),
69+
70+
file_name: Field.text({
71+
label: 'File Name',
72+
required: false,
73+
searchable: true,
74+
maxLength: 255,
75+
description: 'Denormalised copy of sys_file.name for fast list rendering',
76+
group: 'File',
77+
}),
78+
79+
mime_type: Field.text({
80+
label: 'MIME Type',
81+
required: false,
82+
maxLength: 128,
83+
group: 'File',
84+
}),
85+
86+
size: Field.number({
87+
label: 'Size (bytes)',
88+
required: false,
89+
group: 'File',
90+
}),
91+
92+
// ── Sharing ────────────────────────────────────────────────
93+
share_type: Field.select(
94+
['viewer', 'collaborator', 'inferred'],
95+
{
96+
label: 'Share Type',
97+
defaultValue: 'viewer',
98+
description: 'viewer | collaborator | inferred (inherited from parent record)',
99+
group: 'Sharing',
100+
},
101+
),
102+
103+
visibility: Field.select(
104+
['internal', 'all_users', 'shared_users'],
105+
{
106+
label: 'Visibility',
107+
defaultValue: 'internal',
108+
group: 'Sharing',
109+
},
110+
),
111+
112+
// ── Authoring ──────────────────────────────────────────────
113+
uploaded_by: Field.lookup('sys_user', {
114+
label: 'Uploaded By',
115+
required: false,
116+
group: 'System',
117+
}),
118+
119+
description: Field.textarea({
120+
label: 'Description',
121+
required: false,
122+
maxLength: 1024,
123+
group: 'File',
124+
}),
125+
126+
created_at: Field.datetime({
127+
label: 'Created At',
128+
required: true,
129+
defaultValue: 'NOW()',
130+
readonly: true,
131+
group: 'System',
132+
}),
133+
134+
updated_at: Field.datetime({
135+
label: 'Updated At',
136+
required: false,
137+
group: 'System',
138+
}),
139+
},
140+
141+
indexes: [
142+
{ fields: ['parent_object', 'parent_id', 'created_at'] },
143+
{ fields: ['file_id'] },
144+
{ fields: ['uploaded_by'] },
145+
],
146+
147+
enable: {
148+
trackHistory: false,
149+
searchable: true,
150+
apiEnabled: true,
151+
trash: true,
152+
mru: false,
153+
clone: false,
154+
},
155+
});

packages/plugins/plugin-audit/src/audit-plugin.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import type { Plugin, PluginContext } from '@objectstack/core';
44
import type { IDataEngine } from '@objectstack/spec/contracts';
5-
import { SysAuditLog, SysActivity, SysComment } from '@objectstack/platform-objects/audit';
5+
import { SysAuditLog, SysActivity, SysComment, SysAttachment } from '@objectstack/platform-objects/audit';
66
import { installAuditWriters } from './audit-writers.js';
77

88
/**
@@ -30,7 +30,7 @@ export class AuditPlugin implements Plugin {
3030
scope: 'system',
3131
defaultDatasource: 'cloud',
3232
namespace: 'sys',
33-
objects: [SysAuditLog, SysActivity, SysComment],
33+
objects: [SysAuditLog, SysActivity, SysComment, SysAttachment],
3434
});
3535

3636
ctx.logger.info('Audit Plugin initialized');

0 commit comments

Comments
 (0)