@ossy/resources 1.6.0 → 1.8.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ossy/resources",
3
3
  "description": "Resource domain — aggregate and events for the Ossy resource model",
4
- "version": "1.6.0",
4
+ "version": "1.8.0",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
@@ -9,11 +9,20 @@
9
9
  "exports": {
10
10
  ".": "./src/index.js"
11
11
  },
12
+ "scripts": {
13
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest --verbose"
14
+ },
12
15
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
13
16
  "license": "MIT",
14
17
  "ossy": {
15
18
  "src": "./src"
16
19
  },
20
+ "devDependencies": {
21
+ "@jest/globals": "^30.2.0",
22
+ "@ossy/platform": "^1.35.0",
23
+ "casual": "^1.6.2",
24
+ "jest": "^30.2.0"
25
+ },
17
26
  "publishConfig": {
18
27
  "access": "public",
19
28
  "registry": "https://registry.npmjs.org"
@@ -22,5 +31,5 @@
22
31
  "/src",
23
32
  "README.md"
24
33
  ],
25
- "gitHead": "46cdd391ec01ea9210543ee7b14661f77079a7e7"
34
+ "gitHead": "24df2bde0d5d8794c5a82b9e802a2594ca73a5d9"
26
35
  }
@@ -0,0 +1,134 @@
1
+ import { accessFilter } from './resources.queries.js'
2
+
3
+ // Helper to build a minimal Policy aggregate document that policyToQueryClause expects.
4
+ function makePolicy({ effect = 'allow', actions = ['resource:read'], where = {} } = {}) {
5
+ return {
6
+ state: {
7
+ effect,
8
+ actions,
9
+ where: {
10
+ workspace: '*',
11
+ location: '*',
12
+ type: '*',
13
+ ...where,
14
+ },
15
+ },
16
+ }
17
+ }
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // accessFilter
21
+ // ---------------------------------------------------------------------------
22
+
23
+ describe('accessFilter', () => {
24
+
25
+ describe('anonymous user', () => {
26
+ it('returns only the public condition', () => {
27
+ const filter = accessFilter({ anonymous: true })
28
+ expect(filter).toEqual({
29
+ $or: [
30
+ { 'state.access': 'public' },
31
+ ],
32
+ })
33
+ })
34
+ })
35
+
36
+ describe('authenticated user with no workspaces and no policies', () => {
37
+ it('returns only the public condition', () => {
38
+ const user = { id: 'u-1', workspaces: [], policies: [] }
39
+ const filter = accessFilter(user)
40
+ expect(filter).toEqual({
41
+ $or: [
42
+ { 'state.access': 'public' },
43
+ ],
44
+ })
45
+ })
46
+ })
47
+
48
+ describe('authenticated user with workspaces', () => {
49
+ it('includes workspace condition with all workspace ids in $in', () => {
50
+ const user = { id: 'u-1', workspaces: ['ws-1', 'ws-2'], policies: [] }
51
+ const filter = accessFilter(user)
52
+ expect(filter.$or).toContainEqual({
53
+ 'state.access': { $in: ['public', 'workspace'] },
54
+ 'state.belongsTo': { $in: ['ws-1', 'ws-2'] },
55
+ })
56
+ })
57
+
58
+ it('still includes the public condition alongside the workspace condition', () => {
59
+ const user = { id: 'u-1', workspaces: ['ws-1'], policies: [] }
60
+ const filter = accessFilter(user)
61
+ expect(filter.$or).toContainEqual({ 'state.access': 'public' })
62
+ })
63
+
64
+ it('includes all workspace ids in the $in clause', () => {
65
+ const workspaces = ['ws-a', 'ws-b', 'ws-c']
66
+ const filter = accessFilter({ id: 'u-1', workspaces, policies: [] })
67
+ const workspaceCondition = filter.$or.find(c => c['state.belongsTo'])
68
+ expect(workspaceCondition['state.belongsTo'].$in).toEqual(workspaces)
69
+ })
70
+ })
71
+
72
+ describe('authenticated user with allow policies', () => {
73
+ it('includes a restricted condition for each matching allow policy', () => {
74
+ const policy = makePolicy({ where: { workspace: 'ws-1' } })
75
+ const user = { id: 'u-1', workspaces: [], policies: [policy] }
76
+ const filter = accessFilter(user)
77
+ expect(filter.$or).toContainEqual(
78
+ expect.objectContaining({ 'state.access': 'restricted', 'state.belongsTo': 'ws-1' })
79
+ )
80
+ })
81
+
82
+ it('policy with effect deny is not included', () => {
83
+ const denyPolicy = makePolicy({ effect: 'deny' })
84
+ const user = { id: 'u-1', workspaces: [], policies: [denyPolicy] }
85
+ const filter = accessFilter(user)
86
+ const restrictedConditions = filter.$or.filter(c => c['state.access'] === 'restricted')
87
+ expect(restrictedConditions).toHaveLength(0)
88
+ })
89
+
90
+ it('policy without resource:read action is not included', () => {
91
+ const writeOnlyPolicy = makePolicy({ actions: ['resource:write'] })
92
+ const user = { id: 'u-1', workspaces: [], policies: [writeOnlyPolicy] }
93
+ const filter = accessFilter(user)
94
+ const restrictedConditions = filter.$or.filter(c => c['state.access'] === 'restricted')
95
+ expect(restrictedConditions).toHaveLength(0)
96
+ })
97
+
98
+ it('wildcard workspace policy does not add state.belongsTo to the clause', () => {
99
+ const policy = makePolicy({ where: { workspace: '*' } })
100
+ const user = { id: 'u-1', workspaces: [], policies: [policy] }
101
+ const filter = accessFilter(user)
102
+ const restrictedCondition = filter.$or.find(c => c['state.access'] === 'restricted')
103
+ expect(restrictedCondition['state.belongsTo']).toBeUndefined()
104
+ })
105
+
106
+ it('includes one restricted clause per valid allow policy', () => {
107
+ const policies = [
108
+ makePolicy({ where: { workspace: 'ws-1' } }),
109
+ makePolicy({ where: { workspace: 'ws-2' } }),
110
+ ]
111
+ const user = { id: 'u-1', workspaces: [], policies }
112
+ const filter = accessFilter(user)
113
+ const restrictedConditions = filter.$or.filter(c => c['state.access'] === 'restricted')
114
+ expect(restrictedConditions).toHaveLength(2)
115
+ })
116
+ })
117
+
118
+ describe('null / undefined user', () => {
119
+ it('returns only the public condition for null user', () => {
120
+ const filter = accessFilter(null)
121
+ expect(filter.$or).toContainEqual({ 'state.access': 'public' })
122
+ const nonPublic = filter.$or.filter(c => c['state.access'] !== 'public')
123
+ expect(nonPublic).toHaveLength(0)
124
+ })
125
+
126
+ it('returns only the public condition for undefined user', () => {
127
+ const filter = accessFilter(undefined)
128
+ expect(filter.$or).toContainEqual({ 'state.access': 'public' })
129
+ const nonPublic = filter.$or.filter(c => c['state.access'] !== 'public')
130
+ expect(nonPublic).toHaveLength(0)
131
+ })
132
+ })
133
+
134
+ })
@@ -0,0 +1,38 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { Resource, ResourcesEvents } from '@ossy/resources'
3
+ import { Workspace } from '@ossy/workspaces'
4
+ import { normalizeAndValidateDocumentContent } from '@ossy/platform'
5
+
6
+ export const id = 'resources/create-page-view'
7
+ export const access = 'workspace'
8
+
9
+ const PAGE_VIEW_TEMPLATE_ID = '@ossy/web/page-view'
10
+ const PAGE_VIEW_LOCATION = '/@ossy/analytics/page-views/'
11
+
12
+ export async function run({ payload, req }) {
13
+ const workspaceId = payload?.workspaceId ?? req?.workspaceId
14
+ if (!workspaceId) throw Object.assign(new Error('workspaceId is required'), { status: 400 })
15
+
16
+ const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
17
+
18
+ // Page-view template is a built-in; instantiate directly without schema validation
19
+ const contentInput = {
20
+ ...(payload ?? {}),
21
+ userAgent: payload?.userAgent ?? req?.headers?.['user-agent'],
22
+ eventAt: payload?.eventAt ?? Date.now(),
23
+ path: payload?.path ?? '/',
24
+ }
25
+
26
+ const event = ResourcesEvents.Created({
27
+ type: PAGE_VIEW_TEMPLATE_ID,
28
+ createdBy: payload?.userId ?? req?.userId ?? 'public',
29
+ belongsTo: workspace.id,
30
+ location: PAGE_VIEW_LOCATION,
31
+ name: `${Date.now()}`,
32
+ content: contentInput,
33
+ access: 'restricted',
34
+ })
35
+
36
+ const resource = await Aggregate.Of(Resource, event).then(Aggregate.View())
37
+ return { id: resource.id }
38
+ }
@@ -0,0 +1,98 @@
1
+ import { is } from 'ramda'
2
+ import { nanoid } from 'nanoid'
3
+ import { Aggregate } from '@ossy/event-store'
4
+ import { Resource, ResourcesEvents } from '@ossy/resources'
5
+ import { Workspace } from '@ossy/workspaces'
6
+ import { getSystemResourceTemplates, normalizeAndValidateDocumentContent } from '@ossy/platform'
7
+
8
+ export const id = 'resources/create'
9
+ export const access = 'workspace'
10
+
11
+ const NativeResourceTypes = { DIRECTORY: 'directory' }
12
+
13
+ export async function run({ payload, integrations, req }) {
14
+ const createdBy = payload?.userId ?? req?.userId
15
+ const workspaceId = payload?.workspaceId ?? req?.workspaceId
16
+
17
+ const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
18
+ let location = payload?.location
19
+ const type = payload?.type
20
+ const name = payload?.name
21
+ const fileExtension = name?.split?.('.')?.pop?.()
22
+ const content = payload?.content
23
+ const size = payload?.size
24
+
25
+ if (!is(String, location)) {
26
+ throw Object.assign(new Error(`Not a valid location path: ${location}`), { status: 400, type: 'INVALID_PATH' })
27
+ }
28
+
29
+ location = location.endsWith('/') ? location : location + '/'
30
+ location = location.startsWith('/') ? location : '/' + location
31
+
32
+ const systemTemplates = getSystemResourceTemplates()
33
+ const allTemplates = [
34
+ ...systemTemplates,
35
+ ...(workspace.resourceTemplates || []).filter(t => !systemTemplates.find(s => s.id === t.id)),
36
+ ]
37
+ const isResourceTemplateType = allTemplates.map(t => t.id).includes(type)
38
+
39
+ if (type === NativeResourceTypes.DIRECTORY) {
40
+ const event = ResourcesEvents.Created({
41
+ type: NativeResourceTypes.DIRECTORY,
42
+ createdBy,
43
+ belongsTo: workspace.id,
44
+ location,
45
+ name,
46
+ access: 'workspace',
47
+ })
48
+ return Aggregate.Of(Resource, event).then(Aggregate.View())
49
+ }
50
+
51
+ if (isResourceTemplateType) {
52
+ const template = allTemplates.find(t => t.id === type)
53
+ const normalized = normalizeAndValidateDocumentContent(content, template)
54
+ if (!normalized.ok) {
55
+ throw Object.assign(new Error(normalized.message), { status: 400, type: normalized.code })
56
+ }
57
+ const event = ResourcesEvents.Created({
58
+ type,
59
+ createdBy,
60
+ belongsTo: workspace.id,
61
+ location,
62
+ name,
63
+ content: normalized.content,
64
+ })
65
+ return Aggregate.Of(Resource, event).then(Aggregate.View())
66
+ }
67
+
68
+ if (!fileExtension) {
69
+ throw Object.assign(new Error('Missing valid file extension'), { status: 400 })
70
+ }
71
+
72
+ const aggregateId = nanoid()
73
+ const event = ResourcesEvents.Created({
74
+ aggregateId,
75
+ type,
76
+ createdBy,
77
+ belongsTo: workspace.id,
78
+ location,
79
+ name,
80
+ content: {
81
+ Key: `media/${workspace.id}/${aggregateId}/original.${fileExtension}`,
82
+ ContentLength: size,
83
+ ContentType: type,
84
+ },
85
+ })
86
+
87
+ const storageClient = integrations?.get?.('storage')
88
+ const uploadHeaders = {
89
+ ContentLength: event.payload.content.ContentLength,
90
+ ContentType: event.payload.content.ContentType,
91
+ Key: event.payload.content.Key,
92
+ }
93
+ const uploadUrl = storageClient ? await storageClient.createUploadUrl(uploadHeaders) : null
94
+ const resource = await Aggregate.Of(Resource, event).then(Aggregate.View())
95
+ return uploadUrl
96
+ ? { ...resource, content: { ...resource.content, uploadUrl } }
97
+ : resource
98
+ }
@@ -0,0 +1,31 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { Resource, ResourcesEvents } from '@ossy/resources'
3
+ import { ResourcesQueries } from './resources.queries.js'
4
+
5
+ export const id = 'resources/delete'
6
+ export const access = 'workspace'
7
+
8
+ const DIRECTORY_TYPE = 'directory'
9
+
10
+ export async function run({ payload, req }) {
11
+ const createdBy = payload?.userId ?? req?.userId
12
+ const resourceId = payload?.resourceId ?? req?.params?.resourceId
13
+
14
+ if (!resourceId) throw Object.assign(new Error('resourceId is required'), { status: 400 })
15
+
16
+ const aggregate = await Aggregate.Of(Resource, resourceId)
17
+ const resource = Aggregate.View()(aggregate)
18
+
19
+ if (resource.type === DIRECTORY_TYPE) {
20
+ const nestedResources = await ResourcesQueries.GetNestedResources({
21
+ location: resource.location + resource.name + '/',
22
+ })
23
+ await Promise.all(nestedResources.map(nested =>
24
+ Aggregate.Of(Resource, nested.id)
25
+ .then(Aggregate.Add(ResourcesEvents.Deleted({ createdBy })))
26
+ ))
27
+ }
28
+
29
+ await Aggregate.Add(ResourcesEvents.Deleted({ createdBy }))(aggregate)
30
+ return null
31
+ }
@@ -0,0 +1,65 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { Workspace } from '@ossy/workspaces'
3
+
4
+ export const id = 'resources/get-page-view-stats'
5
+ export const access = 'workspace'
6
+
7
+ const PAGE_VIEW_TEMPLATE_ID = '@ossy/web/page-view'
8
+ const PAGE_VIEW_LOCATION = '/@ossy/analytics/page-views/'
9
+
10
+ function parseMillis(input, fallback) {
11
+ if (input === undefined || input === null || input === '') return fallback
12
+ const n = Number(input)
13
+ if (!Number.isFinite(n) || n <= 0) return fallback
14
+ return n
15
+ }
16
+
17
+ export async function run({ payload, req }) {
18
+ const workspaceId = payload?.workspaceId ?? req?.workspaceId
19
+ if (!workspaceId) throw Object.assign(new Error('workspaceId is required'), { status: 400 })
20
+
21
+ const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
22
+
23
+ const now = Date.now()
24
+ const defaultFrom = now - (30 * 24 * 60 * 60 * 1000)
25
+ const from = parseMillis(payload?.from ?? req?.query?.from, defaultFrom)
26
+ const to = parseMillis(payload?.to ?? req?.query?.to, now)
27
+ const topLimit = Math.min(Math.max(Number(payload?.limit ?? req?.query?.limit ?? 10), 1), 50)
28
+
29
+ const baseMatch = {
30
+ type: 'Resource',
31
+ 'state.status': { $ne: 'removed' },
32
+ 'state.belongsTo': workspace.id,
33
+ 'state.type': PAGE_VIEW_TEMPLATE_ID,
34
+ 'state.location': PAGE_VIEW_LOCATION,
35
+ 'state.content.eventAt': { $gte: from, $lte: to },
36
+ }
37
+
38
+ const [dailyViews, topPaths, totals] = await Promise.all([
39
+ Aggregate.Collection.aggregate([
40
+ { $match: baseMatch },
41
+ { $project: { date: { $dateToString: { format: '%Y-%m-%d', date: { $toDate: '$state.content.eventAt' } } } } },
42
+ { $group: { _id: '$date', views: { $sum: 1 } } },
43
+ { $sort: { _id: 1 } },
44
+ ]).toArray(),
45
+ Aggregate.Collection.aggregate([
46
+ { $match: baseMatch },
47
+ { $group: { _id: '$state.content.path', views: { $sum: 1 } } },
48
+ { $sort: { views: -1 } },
49
+ { $limit: topLimit },
50
+ { $project: { _id: 0, path: '$_id', views: 1 } },
51
+ ]).toArray(),
52
+ Aggregate.Collection.aggregate([
53
+ { $match: baseMatch },
54
+ { $count: 'views' },
55
+ ]).toArray(),
56
+ ])
57
+
58
+ return {
59
+ from,
60
+ to,
61
+ totalViews: totals?.[0]?.views || 0,
62
+ dailyViews: dailyViews.map(x => ({ date: x._id, views: x.views })),
63
+ topPaths,
64
+ }
65
+ }
@@ -0,0 +1,14 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { Resource } from '@ossy/resources'
3
+
4
+ export const id = 'resources/get'
5
+ export const access = 'workspace'
6
+
7
+ export async function run({ payload, req }) {
8
+ const resourceId = payload?.resourceId ?? req?.params?.resourceId
9
+ if (!resourceId) throw Object.assign(new Error('resourceId is required'), { status: 400 })
10
+
11
+ const resource = await Aggregate.Of(Resource, resourceId).then(Aggregate.View())
12
+ if (!resource?.id) throw Object.assign(new Error('Resource not found'), { status: 404 })
13
+ return resource
14
+ }
@@ -0,0 +1,34 @@
1
+ import { ActionService } from '@ossy/platform'
2
+ import { attachResourceMediaUrls } from './resources.attach-media-urls.js'
3
+
4
+ const pathRe = /^\/api\/v0\/resources\/([^/]+)\/access$/
5
+
6
+ export const metadata = {
7
+ id: 'resources.item.access',
8
+ path: '/api/v0/resources/:resourceId/access',
9
+ }
10
+
11
+ export default async function handle(req, res) {
12
+ if (req.method !== 'PUT') {
13
+ res.setHeader('Allow', 'PUT')
14
+ res.status(405).json({ error: 'Method Not Allowed' })
15
+ return
16
+ }
17
+ const pathname = (req.path || new URL(req.originalUrl, 'http://localhost').pathname).replace(/\/+$/, '')
18
+ const m = pathname.match(pathRe)
19
+ if (!m) {
20
+ res.status(404).json('')
21
+ return
22
+ }
23
+ const resourceId = decodeURIComponent(m[1])
24
+ try {
25
+ const resource = await ActionService.invoke('resources/update-access', {
26
+ payload: { resourceId, access: req.body?.access, userId: req.userId },
27
+ req,
28
+ })
29
+ const withUrls = await attachResourceMediaUrls(resource, { userId: req.userId, workspaceId: req.workspaceId })
30
+ res.json(withUrls)
31
+ } catch (err) {
32
+ res.status(err?.status ?? 500).json({ error: err?.message })
33
+ }
34
+ }
@@ -0,0 +1,35 @@
1
+ import { ActionService } from '@ossy/platform'
2
+ import { attachResourceMediaUrls } from './resources.attach-media-urls.js'
3
+
4
+ const pathRe = /^\/api\/v0\/resources\/([^/]+)\/content$/
5
+
6
+ export const metadata = {
7
+ id: 'resources.item.content',
8
+ path: '/api/v0/resources/:resourceId/content',
9
+ }
10
+
11
+ export default async function handle(req, res) {
12
+ if (req.method !== 'PUT') {
13
+ res.setHeader('Allow', 'PUT')
14
+ res.status(405).json({ error: 'Method Not Allowed' })
15
+ return
16
+ }
17
+ const pathname = (req.path || new URL(req.originalUrl, 'http://localhost').pathname).replace(/\/+$/, '')
18
+ const m = pathname.match(pathRe)
19
+ if (!m) {
20
+ res.status(404).json('')
21
+ return
22
+ }
23
+ const resourceId = decodeURIComponent(m[1])
24
+ try {
25
+ const resource = await ActionService.invoke('resources/update-content', {
26
+ payload: { resourceId, content: req.body?.content, userId: req.userId, workspaceId: req.workspaceId },
27
+ req,
28
+ })
29
+ if (resource === null || resource === undefined) return
30
+ const withUrls = await attachResourceMediaUrls(resource, { userId: req.userId, workspaceId: req.workspaceId })
31
+ res.json(withUrls)
32
+ } catch (err) {
33
+ res.status(err?.status ?? 400).json({ type: err?.type, message: err?.message })
34
+ }
35
+ }
@@ -0,0 +1,34 @@
1
+ import { ActionService } from '@ossy/platform'
2
+ import { attachResourceMediaUrls } from './resources.attach-media-urls.js'
3
+
4
+ const pathRe = /^\/api\/v0\/resources\/([^/]+)\/location$/
5
+
6
+ export const metadata = {
7
+ id: 'resources.item.location',
8
+ path: '/api/v0/resources/:resourceId/location',
9
+ }
10
+
11
+ export default async function handle(req, res) {
12
+ if (req.method !== 'PUT') {
13
+ res.setHeader('Allow', 'PUT')
14
+ res.status(405).json({ error: 'Method Not Allowed' })
15
+ return
16
+ }
17
+ const pathname = (req.path || new URL(req.originalUrl, 'http://localhost').pathname).replace(/\/+$/, '')
18
+ const m = pathname.match(pathRe)
19
+ if (!m) {
20
+ res.status(404).json('')
21
+ return
22
+ }
23
+ const resourceId = decodeURIComponent(m[1])
24
+ try {
25
+ const resource = await ActionService.invoke('resources/update-location', {
26
+ payload: { resourceId, target: req.body?.target, userId: req.userId },
27
+ req,
28
+ })
29
+ const withUrls = await attachResourceMediaUrls(resource, { userId: req.userId, workspaceId: req.workspaceId })
30
+ res.json(withUrls)
31
+ } catch (err) {
32
+ res.status(err?.status ?? 500).json({ error: err?.message })
33
+ }
34
+ }
@@ -0,0 +1,34 @@
1
+ import { ActionService } from '@ossy/platform'
2
+ import { attachResourceMediaUrls } from './resources.attach-media-urls.js'
3
+
4
+ const pathRe = /^\/api\/v0\/resources\/([^/]+)\/name$/
5
+
6
+ export const metadata = {
7
+ id: 'resources.item.name',
8
+ path: '/api/v0/resources/:resourceId/name',
9
+ }
10
+
11
+ export default async function handle(req, res) {
12
+ if (req.method !== 'PUT') {
13
+ res.setHeader('Allow', 'PUT')
14
+ res.status(405).json({ error: 'Method Not Allowed' })
15
+ return
16
+ }
17
+ const pathname = (req.path || new URL(req.originalUrl, 'http://localhost').pathname).replace(/\/+$/, '')
18
+ const m = pathname.match(pathRe)
19
+ if (!m) {
20
+ res.status(404).json('')
21
+ return
22
+ }
23
+ const resourceId = decodeURIComponent(m[1])
24
+ try {
25
+ const resource = await ActionService.invoke('resources/update-name', {
26
+ payload: { resourceId, name: req.body?.name, userId: req.userId },
27
+ req,
28
+ })
29
+ const withUrls = await attachResourceMediaUrls(resource, { userId: req.userId, workspaceId: req.workspaceId })
30
+ res.json(withUrls)
31
+ } catch (err) {
32
+ res.status(err?.status ?? 500).json({ error: err?.message })
33
+ }
34
+ }
@@ -0,0 +1,48 @@
1
+ import { ActionService } from '@ossy/platform'
2
+ import { attachResourceMediaUrls } from './resources.attach-media-urls.js'
3
+
4
+ const pathRe = /^\/api\/v0\/resources\/([^/]+)\/([^/]+)$/
5
+ const reservedSecond = new Set(['name', 'location', 'content', 'access'])
6
+
7
+ export const metadata = {
8
+ id: 'resources.item.version',
9
+ path: '/api/v0/resources/:resourceId/:namedVersion',
10
+ }
11
+
12
+ export default async function handle(req, res) {
13
+ if (req.method !== 'PUT') {
14
+ res.setHeader('Allow', 'PUT')
15
+ res.status(405).json({ error: 'Method Not Allowed' })
16
+ return
17
+ }
18
+ const pathname = (req.path || new URL(req.originalUrl, 'http://localhost').pathname).replace(/\/+$/, '')
19
+ const m = pathname.match(pathRe)
20
+ if (!m) {
21
+ res.status(404).json('')
22
+ return
23
+ }
24
+ const namedVersion = decodeURIComponent(m[2])
25
+ if (reservedSecond.has(namedVersion)) {
26
+ res.status(404).json('')
27
+ return
28
+ }
29
+ const resourceId = decodeURIComponent(m[1])
30
+ try {
31
+ const resource = await ActionService.invoke('resources/upload-named-version', {
32
+ payload: {
33
+ resourceId,
34
+ namedVersion,
35
+ type: req.body?.type,
36
+ size: req.body?.size,
37
+ userId: req.userId,
38
+ workspaceId: req.workspaceId,
39
+ },
40
+ integrations: req.integrations,
41
+ req,
42
+ })
43
+ const withUrls = await attachResourceMediaUrls(resource, { userId: req.userId, workspaceId: req.workspaceId })
44
+ res.json(withUrls)
45
+ } catch (err) {
46
+ res.status(err?.status ?? 400).json({ error: err?.message })
47
+ }
48
+ }
@@ -0,0 +1,58 @@
1
+ import { ActionService } from '@ossy/platform'
2
+ import { attachResourceMediaUrls } from './resources.attach-media-urls.js'
3
+
4
+ const pathRe = /^\/api\/v0\/resources\/([^/]+)$/
5
+
6
+ export const metadata = {
7
+ id: 'resources.item',
8
+ path: '/api/v0/resources/:resourceId',
9
+ }
10
+
11
+ function mediaContext(req) {
12
+ return { userId: req.userId, workspaceId: req.workspaceId }
13
+ }
14
+
15
+ export default async function handle(req, res) {
16
+ const pathname = (req.path || new URL(req.originalUrl, 'http://localhost').pathname).replace(/\/+$/, '')
17
+ const m = pathname.match(pathRe)
18
+ if (!m) {
19
+ res.status(404).json('')
20
+ return
21
+ }
22
+ const segment = decodeURIComponent(m[1])
23
+ const reserved = new Set(['search', 'page-views'])
24
+ if (reserved.has(segment)) {
25
+ res.status(404).json('')
26
+ return
27
+ }
28
+
29
+ req.params = { ...req.params, resourceId: segment }
30
+
31
+ if (req.method === 'GET') {
32
+ try {
33
+ const resource = await ActionService.invoke('resources/get', {
34
+ payload: { resourceId: segment },
35
+ req,
36
+ })
37
+ const withUrls = await attachResourceMediaUrls(resource, mediaContext(req))
38
+ res.json(withUrls)
39
+ } catch (err) {
40
+ res.status(err?.status ?? 500).json({ error: err?.message })
41
+ }
42
+ return
43
+ }
44
+ if (req.method === 'DELETE') {
45
+ try {
46
+ await ActionService.invoke('resources/delete', {
47
+ payload: { resourceId: segment, userId: req.userId },
48
+ req,
49
+ })
50
+ res.status(204).end()
51
+ } catch (err) {
52
+ res.status(err?.status ?? 500).json({ error: err?.message })
53
+ }
54
+ return
55
+ }
56
+ res.setHeader('Allow', 'GET, DELETE')
57
+ res.status(405).json({ error: 'Method Not Allowed' })
58
+ }
@@ -0,0 +1,19 @@
1
+ import { ResourcesQueries } from './resources.queries.js'
2
+
3
+ export const id = 'resources/list'
4
+ export const access = 'workspace'
5
+
6
+ export async function run({ payload, req }) {
7
+ const query = { ...(payload?.query ?? {}) }
8
+ const workspaceId = payload?.workspaceId ?? req?.workspaceId
9
+ const user = payload?.user ?? req?.user
10
+ let location = payload?.location ?? query.location
11
+
12
+ if (location && !location.endsWith('/')) location += '/'
13
+ if (location && !location.startsWith('/')) location = '/' + location
14
+ if (location) query.location = location
15
+
16
+ if (workspaceId) query.belongsTo = workspaceId
17
+
18
+ return ResourcesQueries.GetResources(query, user)
19
+ }
@@ -0,0 +1,43 @@
1
+ import { ActionService } from '@ossy/platform'
2
+ import { attachResourceMediaUrls } from './resources.attach-media-urls.js'
3
+
4
+ export const metadata = {
5
+ id: 'resources.list',
6
+ path: '/api/v0/resources',
7
+ }
8
+
9
+ function mediaContext(req) {
10
+ return { userId: req.userId, workspaceId: req.workspaceId }
11
+ }
12
+
13
+ export default async function handle(req, res) {
14
+ if (req.method === 'POST') {
15
+ try {
16
+ const resource = await ActionService.invoke('resources/create', {
17
+ payload: { ...req.body, userId: req.userId, workspaceId: req.workspaceId },
18
+ integrations: req.integrations,
19
+ req,
20
+ })
21
+ const withUrls = await attachResourceMediaUrls(resource, mediaContext(req))
22
+ res.json(withUrls)
23
+ } catch (err) {
24
+ res.status(err?.status ?? 500).json({ error: err?.message })
25
+ }
26
+ return
27
+ }
28
+ if (req.method === 'GET') {
29
+ try {
30
+ const resources = await ActionService.invoke('resources/list', {
31
+ payload: { query: req.query, workspaceId: req.workspaceId, userId: req.userId, user: req.user },
32
+ req,
33
+ })
34
+ const withUrls = await Promise.all(resources.map(r => attachResourceMediaUrls(r, mediaContext(req))))
35
+ res.json(withUrls)
36
+ } catch (err) {
37
+ res.status(err?.status ?? 500).json({ error: err?.message })
38
+ }
39
+ return
40
+ }
41
+ res.setHeader('Allow', 'GET, POST')
42
+ res.status(405).json({ error: 'Method Not Allowed' })
43
+ }
@@ -0,0 +1,23 @@
1
+ import { ActionService } from '@ossy/platform'
2
+
3
+ export const metadata = {
4
+ id: 'resources.pageViews.stats',
5
+ path: '/api/v0/resources/page-views/stats',
6
+ }
7
+
8
+ export default async function handle(req, res) {
9
+ if (req.method !== 'GET') {
10
+ res.setHeader('Allow', 'GET')
11
+ res.status(405).json({ error: 'Method Not Allowed' })
12
+ return
13
+ }
14
+ try {
15
+ const result = await ActionService.invoke('resources/get-page-view-stats', {
16
+ payload: { workspaceId: req.workspaceId, from: req.query?.from, to: req.query?.to, limit: req.query?.limit },
17
+ req,
18
+ })
19
+ res.json(result)
20
+ } catch (err) {
21
+ res.status(err?.status ?? 500).json({ error: err?.message })
22
+ }
23
+ }
@@ -0,0 +1,23 @@
1
+ import { ActionService } from '@ossy/platform'
2
+
3
+ export const metadata = {
4
+ id: 'resources.pageViews',
5
+ path: '/api/v0/resources/page-views',
6
+ }
7
+
8
+ export default async function handle(req, res) {
9
+ if (req.method !== 'POST') {
10
+ res.setHeader('Allow', 'POST')
11
+ res.status(405).json({ error: 'Method Not Allowed' })
12
+ return
13
+ }
14
+ try {
15
+ const result = await ActionService.invoke('resources/create-page-view', {
16
+ payload: { ...req.body, workspaceId: req.workspaceId, userId: req.userId },
17
+ req,
18
+ })
19
+ res.status(201).json(result)
20
+ } catch (err) {
21
+ res.status(err?.status ?? 500).json({ error: err?.message })
22
+ }
23
+ }
@@ -0,0 +1,60 @@
1
+ import { StorageClient } from '@ossy/platform'
2
+ import { Aggregate } from '@ossy/event-store'
3
+ import { Workspace } from '@ossy/workspaces'
4
+
5
+ /**
6
+ * Adds `content.src` (and `content.sizes[*]` URLs) for file resources, respecting
7
+ * `access` and workspace membership. Restricted resources get short-lived S3 presigned GETs
8
+ * for members only; public resources get CDN URLs for any caller.
9
+ *
10
+ * @param {object} resource - Resource view
11
+ * @param {{ userId?: string, workspaceId?: string }} context
12
+ * @returns {Promise<object>}
13
+ */
14
+ export async function attachResourceMediaUrls(resource, { userId, workspaceId } = {}) {
15
+ if (!resource?.content) return resource
16
+ const workspace = workspaceId
17
+ ? await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
18
+ : null
19
+ const { Key, sizes } = resource.content
20
+ const hasSizes = sizes && typeof sizes === 'object' && Object.keys(sizes).length > 0
21
+ if (!Key && !hasSizes) return resource
22
+
23
+ const access = resource.access || 'restricted'
24
+ const isMember = Boolean(userId && workspace?.users?.includes(userId))
25
+ const allowMedia = access === 'public' || (access === 'restricted' && isMember)
26
+
27
+ const nextContent = { ...resource.content }
28
+
29
+ if (!allowMedia) {
30
+ delete nextContent.Key
31
+ delete nextContent.src
32
+ if (hasSizes) nextContent.sizes = {}
33
+ return { ...resource, content: nextContent }
34
+ }
35
+
36
+ if (Key) {
37
+ nextContent.src =
38
+ access === 'public'
39
+ ? StorageClient.createDownloadUrl(Key)
40
+ : await StorageClient.createPresignedDownloadUrl(Key)
41
+ }
42
+
43
+ if (hasSizes) {
44
+ const nextSizes = {}
45
+ for (const [name, value] of Object.entries(sizes)) {
46
+ if (value == null || typeof value !== 'string') continue
47
+ if (value.startsWith('http://') || value.startsWith('https://')) {
48
+ nextSizes[name] = value
49
+ continue
50
+ }
51
+ nextSizes[name] =
52
+ access === 'public'
53
+ ? StorageClient.createDownloadUrl(value)
54
+ : await StorageClient.createPresignedDownloadUrl(value)
55
+ }
56
+ nextContent.sizes = nextSizes
57
+ }
58
+
59
+ return { ...resource, content: nextContent }
60
+ }
@@ -0,0 +1,95 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { createLogger } from '@ossy/observability'
3
+
4
+ const log = createLogger('resources')
5
+
6
+ function globToRegex(pattern) {
7
+ const escaped = pattern
8
+ .replace(/\./g, '\\.')
9
+ .replace(/\*\*/g, '§§')
10
+ .replace(/\*/g, '[^/]*')
11
+ .replace(/§§/g, '.*')
12
+ return new RegExp('^' + escaped + '$')
13
+ }
14
+
15
+ function policyToQueryClause(policy) {
16
+ const { where, actions, effect } = policy.state
17
+ if (effect !== 'allow' || !actions.includes('resource:read')) return null
18
+
19
+ const clause = { 'state.access': 'restricted' }
20
+
21
+ if (where.workspace && where.workspace !== '*')
22
+ clause['state.belongsTo'] = where.workspace
23
+
24
+ if (where.location && where.location !== '*')
25
+ clause['state.location'] = { $regex: globToRegex(where.location).source }
26
+
27
+ if (where.type && where.type !== '*')
28
+ clause['state.type'] = { $regex: globToRegex(where.type).source }
29
+
30
+ return clause
31
+ }
32
+
33
+ export function accessFilter(user) {
34
+ const conditions = [{ 'state.access': 'public' }]
35
+
36
+ if (!user?.anonymous) {
37
+ if (user?.workspaces?.length) {
38
+ conditions.push({
39
+ 'state.access': { $in: ['public', 'workspace'] },
40
+ 'state.belongsTo': { $in: user.workspaces },
41
+ })
42
+ }
43
+ for (const policy of (user?.policies ?? [])) {
44
+ const clause = policyToQueryClause(policy)
45
+ if (clause) conditions.push(clause)
46
+ }
47
+ }
48
+
49
+ return { $or: conditions }
50
+ }
51
+
52
+ export class ResourcesQueries {
53
+ static #prefixKey(key) {
54
+ if (key.startsWith('$')) return key
55
+ return `state.${key}`
56
+ }
57
+
58
+ static #prefixQuery(query) {
59
+ if (Array.isArray(query)) return query.map(item => ResourcesQueries.#prefixQuery(item))
60
+ if (query !== null && typeof query === 'object') {
61
+ return Object.entries(query).reduce((acc, [key, value]) => {
62
+ const prefixedKey = ResourcesQueries.#prefixKey(key)
63
+ const prefixedValue = key.startsWith('$') ? ResourcesQueries.#prefixQuery(value) : value
64
+ return { ...acc, [prefixedKey]: prefixedValue }
65
+ }, {})
66
+ }
67
+ return query
68
+ }
69
+
70
+ static GetResources(_query = {}, user = null) {
71
+ log.info('[ResourcesQueries][GetResources] Building query')
72
+ const query = ResourcesQueries.#prefixQuery({ ..._query, status: { $ne: 'removed' } })
73
+ const workspaceScope = query['state.belongsTo'] ?? '(no workspace scope)'
74
+ log.info(`[ResourcesQueries][GetResources] Fetching resources for ${workspaceScope}`)
75
+
76
+ const baseQuery = { ...query, type: 'Resource' }
77
+ const mongoQuery = user ? { $and: [baseQuery, accessFilter(user)] } : baseQuery
78
+
79
+ return Aggregate.Collection.find(mongoQuery, { state: true }).toArray()
80
+ .then(resources => {
81
+ log.info(`[ResourcesQueries][GetResources] Found ${resources.length} resources`)
82
+ return resources.map(resource => resource.state)
83
+ })
84
+ }
85
+
86
+ static GetNestedResources({ location }) {
87
+ log.info(`[ResourcesQueries][GetNestedResources] Fetching nested resources for ${location}`)
88
+ const regex = new RegExp(`^${location}`)
89
+ return Aggregate.Collection.find({
90
+ 'state.location': { $regex: regex },
91
+ 'state.status': { $ne: 'removed' },
92
+ }, { state: true }).toArray()
93
+ .then(resources => resources.map(resource => resource.state))
94
+ }
95
+ }
@@ -0,0 +1,147 @@
1
+ import casual from 'casual'
2
+ import { TestUtil } from '@ossy/platform/test'
3
+
4
+ function workspaceHeaders(userToken, workspaceId) {
5
+ return {
6
+ 'Content-Type': 'application/json',
7
+ Authorization: userToken,
8
+ workspaceId,
9
+ }
10
+ }
11
+
12
+ describe('[/resources][POST]', () => {
13
+
14
+ TestUtil.AssertAuthenticationNeeded({
15
+ endpoint: '/resources',
16
+ method: 'POST',
17
+ headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
18
+ })
19
+
20
+ })
21
+
22
+ describe('[/resources][GET]', () => {
23
+ TestUtil.AssertAuthenticationNeeded({
24
+ endpoint: '/resources',
25
+ method: 'GET',
26
+ headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
27
+ })
28
+ })
29
+
30
+ describe('[/resources/:resourceId][GET]', () => {
31
+ TestUtil.AssertAuthenticationNeeded({
32
+ endpoint: '/resources/r1',
33
+ method: 'GET',
34
+ headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
35
+ })
36
+ })
37
+
38
+ describe('[/resources/:resourceId/name][PUT]', () => {
39
+ TestUtil.AssertAuthenticationNeeded({
40
+ endpoint: '/resources/r1/name',
41
+ method: 'PUT',
42
+ headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
43
+ })
44
+ })
45
+
46
+ describe('[/resources/:resourceId/location][PUT]', () => {
47
+ TestUtil.AssertAuthenticationNeeded({
48
+ endpoint: '/resources/r1/location',
49
+ method: 'PUT',
50
+ headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
51
+ })
52
+ })
53
+
54
+ describe('[/resources/:resourceId/content][PUT]', () => {
55
+ TestUtil.AssertAuthenticationNeeded({
56
+ endpoint: '/resources/r1/content',
57
+ method: 'PUT',
58
+ headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
59
+ })
60
+ })
61
+
62
+ describe('[/resources/:resourceId][DELETE]', () => {
63
+ TestUtil.AssertAuthenticationNeeded({
64
+ endpoint: '/resources/r1',
65
+ method: 'DELETE',
66
+ headers: { 'Content-Type': 'application/json', 'workspaceId': 'ws-test' },
67
+ })
68
+ })
69
+
70
+ describe('E2E: template-backed resource (workspaceId header)', () => {
71
+ it('create → update content → delete', async () => {
72
+ const user = await TestUtil.GetAuthenticatedTestUser()
73
+ const workspace = await TestUtil.MakeRequest({
74
+ endpoint: '/workspaces',
75
+ method: 'POST',
76
+ headers: { 'Content-Type': 'application/json', Authorization: user.token },
77
+ body: JSON.stringify({ name: casual.word }),
78
+ }).then(r => r.json())
79
+
80
+ const templateType = '@ossy/web/page'
81
+ const createBody = {
82
+ location: '/',
83
+ type: templateType,
84
+ name: `page-${casual.word}.json`,
85
+ content: {
86
+ title: 'Hello',
87
+ description: 'D',
88
+ body: '<p>initial</p>',
89
+ },
90
+ }
91
+
92
+ const createRes = await TestUtil.MakeRequest({
93
+ endpoint: '/resources',
94
+ method: 'POST',
95
+ headers: workspaceHeaders(user.token, workspace.id),
96
+ body: JSON.stringify(createBody),
97
+ })
98
+
99
+ expect(createRes.status).toEqual(200)
100
+ const created = await createRes.json()
101
+ expect(created.type).toEqual(templateType)
102
+ expect(created.content).toEqual({
103
+ title: 'Hello',
104
+ description: 'D',
105
+ body: '<p>initial</p>',
106
+ })
107
+
108
+ const updateRes = await TestUtil.MakeRequest({
109
+ endpoint: `/resources/${created.id}/content`,
110
+ method: 'PUT',
111
+ headers: workspaceHeaders(user.token, workspace.id),
112
+ body: JSON.stringify({
113
+ content: {
114
+ title: 'Updated',
115
+ description: 'D2',
116
+ body: '<p>revised</p>',
117
+ },
118
+ }),
119
+ })
120
+
121
+ expect(updateRes.status).toEqual(200)
122
+ const updated = await updateRes.json()
123
+ expect(updated.content).toEqual({
124
+ title: 'Updated',
125
+ description: 'D2',
126
+ body: '<p>revised</p>',
127
+ })
128
+
129
+ const deleteRes = await TestUtil.MakeRequest({
130
+ endpoint: `/resources/${created.id}`,
131
+ method: 'DELETE',
132
+ headers: workspaceHeaders(user.token, workspace.id),
133
+ })
134
+
135
+ expect(deleteRes.status).toEqual(204)
136
+
137
+ const listRes = await TestUtil.MakeRequest({
138
+ endpoint: '/resources?location=/',
139
+ method: 'GET',
140
+ headers: workspaceHeaders(user.token, workspace.id),
141
+ })
142
+
143
+ expect(listRes.status).toEqual(200)
144
+ const list = await listRes.json()
145
+ expect(list.some(r => r.id === created.id)).toBe(false)
146
+ })
147
+ })
@@ -0,0 +1,19 @@
1
+ import { ResourcesQueries } from './resources.queries.js'
2
+
3
+ export const id = 'resources/search'
4
+ export const access = 'workspace'
5
+
6
+ export async function run({ payload, req }) {
7
+ const query = { ...(payload ?? {}) }
8
+ const workspaceId = payload?.workspaceId ?? req?.workspaceId
9
+ const user = payload?.user ?? req?.user
10
+ let location = query.location
11
+
12
+ if (location && !location.endsWith('/')) location += '/'
13
+ if (location && !location.startsWith('/')) location = '/' + location
14
+ if (location) query.location = location
15
+
16
+ if (workspaceId) query.belongsTo = workspaceId
17
+
18
+ return ResourcesQueries.GetResources(query, user)
19
+ }
@@ -0,0 +1,27 @@
1
+ import { ActionService } from '@ossy/platform'
2
+ import { attachResourceMediaUrls } from './resources.attach-media-urls.js'
3
+
4
+ export const metadata = {
5
+ id: 'resources.search',
6
+ path: '/api/v0/resources/search',
7
+ }
8
+
9
+ export default async function handle(req, res) {
10
+ if (req.method !== 'POST') {
11
+ res.setHeader('Allow', 'POST')
12
+ res.status(405).json({ error: 'Method Not Allowed' })
13
+ return
14
+ }
15
+ try {
16
+ const resources = await ActionService.invoke('resources/search', {
17
+ payload: { ...req.body, workspaceId: req.workspaceId, user: req.user },
18
+ req,
19
+ })
20
+ const withUrls = await Promise.all(
21
+ resources.map(r => attachResourceMediaUrls(r, { userId: req.userId, workspaceId: req.workspaceId }))
22
+ )
23
+ res.json(withUrls)
24
+ } catch (err) {
25
+ res.status(err?.status ?? 500).json({ error: err?.message })
26
+ }
27
+ }
@@ -0,0 +1,20 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { Resource, ResourcesEvents } from '@ossy/resources'
3
+
4
+ export const id = 'resources/update-access'
5
+ export const access = 'workspace'
6
+
7
+ export async function run({ payload, req }) {
8
+ const createdBy = payload?.userId ?? req?.userId
9
+ const resourceId = payload?.resourceId ?? req?.params?.resourceId
10
+ const resourceAccess = payload?.access
11
+
12
+ if (!resourceId) throw Object.assign(new Error('resourceId is required'), { status: 400 })
13
+ if (!['public', 'restricted'].includes(resourceAccess)) {
14
+ throw Object.assign(new Error(`Invalid access value: ${resourceAccess}`), { status: 400 })
15
+ }
16
+
17
+ return Aggregate.Of(Resource, resourceId)
18
+ .then(Aggregate.Add(ResourcesEvents.AccessUpdated({ createdBy, access: resourceAccess })))
19
+ .then(Aggregate.View())
20
+ }
@@ -0,0 +1,39 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { Resource, ResourcesEvents } from '@ossy/resources'
3
+ import { Workspace } from '@ossy/workspaces'
4
+ import { getSystemResourceTemplates, normalizeAndValidateDocumentContent } from '@ossy/platform'
5
+
6
+ export const id = 'resources/update-content'
7
+ export const access = 'workspace'
8
+
9
+ export async function run({ payload, req }) {
10
+ const createdBy = payload?.userId ?? req?.userId
11
+ const resourceId = payload?.resourceId ?? req?.params?.resourceId
12
+ const content = payload?.content
13
+ const workspaceId = payload?.workspaceId ?? req?.workspaceId
14
+
15
+ if (!resourceId) throw Object.assign(new Error('resourceId is required'), { status: 400 })
16
+
17
+ const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
18
+
19
+ return Aggregate.Of(Resource, resourceId)
20
+ .then(aggregate => {
21
+ const resource = Aggregate.View()(aggregate)
22
+ const systemTemplates = getSystemResourceTemplates()
23
+ const allTemplates = [
24
+ ...systemTemplates,
25
+ ...(workspace.resourceTemplates || []).filter(t => !systemTemplates.find(s => s.id === t.id)),
26
+ ]
27
+ const template = allTemplates.find(t => t.id === resource.type)
28
+ let finalContent = content
29
+ if (template) {
30
+ const normalized = normalizeAndValidateDocumentContent(content, template)
31
+ if (!normalized.ok) {
32
+ throw Object.assign(new Error(normalized.message), { status: 400, type: normalized.code })
33
+ }
34
+ finalContent = normalized.content
35
+ }
36
+ return Aggregate.Add(ResourcesEvents.ContentUpdated({ createdBy, content: finalContent }))(aggregate)
37
+ })
38
+ .then(Aggregate.View())
39
+ }
@@ -0,0 +1,18 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { Resource, ResourcesEvents } from '@ossy/resources'
3
+
4
+ export const id = 'resources/update-location'
5
+ export const access = 'workspace'
6
+
7
+ export async function run({ payload, req }) {
8
+ const createdBy = payload?.userId ?? req?.userId
9
+ const resourceId = payload?.resourceId ?? req?.params?.resourceId
10
+ const location = payload?.target ?? payload?.location
11
+
12
+ if (!resourceId) throw Object.assign(new Error('resourceId is required'), { status: 400 })
13
+ if (!location) throw Object.assign(new Error('target location is required'), { status: 400 })
14
+
15
+ return Aggregate.Of(Resource, resourceId)
16
+ .then(Aggregate.Add(ResourcesEvents.LocationUpdated({ createdBy, location })))
17
+ .then(Aggregate.View())
18
+ }
@@ -0,0 +1,18 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { Resource, ResourcesEvents } from '@ossy/resources'
3
+
4
+ export const id = 'resources/update-name'
5
+ export const access = 'workspace'
6
+
7
+ export async function run({ payload, req }) {
8
+ const createdBy = payload?.userId ?? req?.userId
9
+ const resourceId = payload?.resourceId ?? req?.params?.resourceId
10
+ const name = payload?.name
11
+
12
+ if (!resourceId) throw Object.assign(new Error('resourceId is required'), { status: 400 })
13
+ if (!name) throw Object.assign(new Error('name is required'), { status: 400 })
14
+
15
+ return Aggregate.Of(Resource, resourceId)
16
+ .then(Aggregate.Add(ResourcesEvents.NameUpdated({ createdBy, name })))
17
+ .then(Aggregate.View())
18
+ }
@@ -0,0 +1,42 @@
1
+ import { Aggregate } from '@ossy/event-store'
2
+ import { Resource, ResourcesEvents } from '@ossy/resources'
3
+ import { Workspace } from '@ossy/workspaces'
4
+
5
+ export const id = 'resources/upload-named-version'
6
+ export const access = 'workspace'
7
+
8
+ export async function run({ payload, integrations, req }) {
9
+ const createdBy = payload?.userId ?? req?.userId
10
+ const workspaceId = payload?.workspaceId ?? req?.workspaceId
11
+ const resourceId = payload?.resourceId ?? req?.params?.resourceId
12
+ const namedVersion = payload?.namedVersion ?? req?.params?.namedVersion
13
+ const type = payload?.type
14
+ const size = payload?.size
15
+
16
+ if (!resourceId) throw Object.assign(new Error('resourceId is required'), { status: 400 })
17
+ if (!namedVersion) throw Object.assign(new Error('namedVersion is required'), { status: 400 })
18
+
19
+ const workspace = await Aggregate.Of(Workspace, workspaceId).then(Aggregate.View())
20
+
21
+ const event = ResourcesEvents.NamedVersionUploaded({
22
+ createdBy,
23
+ namedVersion,
24
+ Key: `media/${workspace.id}/${resourceId}/${namedVersion}.${type.replace('image/', '')}`,
25
+ ContentLength: size,
26
+ ContentType: type,
27
+ })
28
+
29
+ const storageClient = integrations?.get?.('storage')
30
+ const uploadHeaders = {
31
+ ContentLength: event.payload.ContentLength,
32
+ ContentType: event.payload.ContentType,
33
+ Key: event.payload.Key,
34
+ }
35
+ const uploadUrl = storageClient ? await storageClient.createUploadUrl(uploadHeaders) : null
36
+ const resource = await Aggregate.Of(Resource, resourceId)
37
+ .then(Aggregate.Add(event))
38
+ .then(Aggregate.View())
39
+ return uploadUrl
40
+ ? { ...resource, content: { ...resource.content, uploadUrl } }
41
+ : resource
42
+ }