@open-mercato/core 0.6.3-develop.3810.1.ad92c339f5 → 0.6.3-develop.3820.1.636677865b
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +37 -1
- package/dist/modules/customers/api/assignable-staff/route.js +14 -153
- package/dist/modules/customers/api/assignable-staff/route.js.map +2 -2
- package/dist/modules/customers/components/detail/assignableStaff.js +1 -1
- package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
- package/dist/modules/planner/api/access.js +20 -53
- package/dist/modules/planner/api/access.js.map +2 -2
- package/dist/modules/staff/api/team-members/assignable/route.js +214 -0
- package/dist/modules/staff/api/team-members/assignable/route.js.map +7 -0
- package/dist/modules/staff/di.js +14 -0
- package/dist/modules/staff/di.js.map +7 -0
- package/dist/modules/staff/lib/availabilityAccess.js +73 -0
- package/dist/modules/staff/lib/availabilityAccess.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/auth/AGENTS.md +27 -0
- package/src/modules/catalog/AGENTS.md +21 -2
- package/src/modules/currencies/AGENTS.md +21 -2
- package/src/modules/customer_accounts/AGENTS.md +26 -6
- package/src/modules/customers/AGENTS.md +20 -1
- package/src/modules/customers/api/assignable-staff/route.ts +14 -185
- package/src/modules/customers/components/detail/assignableStaff.ts +1 -1
- package/src/modules/data_sync/AGENTS.md +30 -11
- package/src/modules/integrations/AGENTS.md +31 -13
- package/src/modules/planner/api/access.ts +29 -55
- package/src/modules/progress/AGENTS.md +20 -2
- package/src/modules/sales/AGENTS.md +24 -5
- package/src/modules/staff/AGENTS.md +78 -0
- package/src/modules/staff/api/team-members/assignable/route.ts +257 -0
- package/src/modules/staff/di.ts +20 -0
- package/src/modules/staff/lib/availabilityAccess.ts +90 -0
- package/src/modules/workflows/AGENTS.md +25 -3
package/.turbo/turbo-build.log
CHANGED
package/AGENTS.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
`@open-mercato/core` contains all core business modules (auth, catalog, customers, sales, etc.). This guide covers the full extensibility contract and module development patterns.
|
|
4
4
|
|
|
5
|
+
## Always
|
|
6
|
+
|
|
7
|
+
- Preserve auto-discovery contracts for module files, API routes, pages, subscribers, workers, widgets, and generated registries.
|
|
8
|
+
- Export `openApi` from every API route file.
|
|
9
|
+
- Use `makeCrudRoute` with `indexer: { entityType }` for CRUD routes that should participate in query indexing.
|
|
10
|
+
- Wire custom write routes through the mutation guard contract.
|
|
11
|
+
- Use declarative feature guards and add new `acl.ts` features to `setup.ts` `defaultRoleFeatures`.
|
|
12
|
+
- Use `findWithDecryption` / `findOneWithDecryption` for encrypted entities.
|
|
13
|
+
- Implement domain writes through commands so audit, undo, cache, events, and indexing stay consistent.
|
|
14
|
+
- Run `yarn generate` after changing module files discovered by the generator.
|
|
15
|
+
|
|
16
|
+
## Ask First
|
|
17
|
+
|
|
18
|
+
- Ask before changing any contract surface from `BACKWARD_COMPATIBILITY.md`: auto-discovery, public types, import paths, event IDs, widget spot IDs, API URLs, DB schema, DI names, ACL features, notification IDs, CLI commands, or generated file contracts.
|
|
19
|
+
- Ask before moving versioned generated files or changing where generated registries live.
|
|
20
|
+
- Ask before applying migrations with `yarn db:migrate`; normal PRs should include migration files and snapshots.
|
|
21
|
+
|
|
22
|
+
## Never
|
|
23
|
+
|
|
24
|
+
- Never create direct ORM relationships between modules; use foreign key IDs and fetch separately.
|
|
25
|
+
- Never expose cross-tenant data or omit tenant/organization scoping.
|
|
26
|
+
- Never hand-edit generated files.
|
|
27
|
+
- Never import generated app bootstrap files from packages.
|
|
28
|
+
- Never run raw `em.find` / `em.findOne` between scalar mutations and `em.flush()` on the same `EntityManager` without `withAtomicFlush`.
|
|
29
|
+
- Never hand-roll AES/KMS encryption or bypass `TenantDataEncryptionService`.
|
|
30
|
+
- Never compare raw feature arrays with exact string checks when wildcard grants apply.
|
|
31
|
+
|
|
32
|
+
## Validation Commands
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
yarn db:generate
|
|
36
|
+
yarn generate
|
|
37
|
+
yarn workspace @open-mercato/core build
|
|
38
|
+
yarn workspace @open-mercato/core test
|
|
39
|
+
```
|
|
40
|
+
|
|
5
41
|
## Core Modules
|
|
6
42
|
|
|
7
43
|
| Module | Path | Description |
|
|
@@ -593,7 +629,7 @@ const crud = makeCrudRoute({
|
|
|
593
629
|
})
|
|
594
630
|
```
|
|
595
631
|
|
|
596
|
-
###
|
|
632
|
+
### Response Enricher Rules
|
|
597
633
|
|
|
598
634
|
- MUST implement `enrichMany()` for batch endpoints (prevents N+1 queries)
|
|
599
635
|
- MUST namespace enriched fields with `_moduleName` prefix (e.g. `_example.todoCount`)
|
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import { NextResponse } from "next/server";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { CrudHttpError, isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
4
|
-
import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
5
|
-
import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
6
|
-
import { User } from "@open-mercato/core/modules/auth/data/entities";
|
|
7
|
-
import { StaffTeam, StaffTeamMember } from "@open-mercato/core/modules/staff/data/entities";
|
|
8
3
|
import { createPagedListResponseSchema } from "../openapi.js";
|
|
9
|
-
import { resolveAuthActorId, resolveCustomersRequestContext } from "../../lib/interactionRequestContext.js";
|
|
10
4
|
const querySchema = z.object({
|
|
11
5
|
page: z.coerce.number().min(1).default(1),
|
|
12
6
|
pageSize: z.coerce.number().min(1).max(100).default(24),
|
|
@@ -32,164 +26,31 @@ const errorSchema = z.object({ error: z.string() });
|
|
|
32
26
|
const metadata = {
|
|
33
27
|
GET: { requireAuth: true, requireFeatures: ["customers.roles.view"] }
|
|
34
28
|
};
|
|
35
|
-
async function canAccessAssignableStaff(rbac, userId, scope) {
|
|
36
|
-
if (!rbac) return false;
|
|
37
|
-
if (await rbac.userHasAllFeatures(userId, ["customers.roles.manage"], scope)) {
|
|
38
|
-
return true;
|
|
39
|
-
}
|
|
40
|
-
return rbac.userHasAllFeatures(userId, ["customers.activities.manage"], scope);
|
|
41
|
-
}
|
|
42
29
|
async function GET(request) {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (!selectedOrganizationId) {
|
|
48
|
-
throw new CrudHttpError(
|
|
49
|
-
400,
|
|
50
|
-
{ error: translate("customers.errors.organization_required", "Organization context is required") }
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
const actorId = resolveAuthActorId(auth);
|
|
54
|
-
const rbacService = container.resolve("rbacService");
|
|
55
|
-
const scope = { tenantId: auth.tenantId, organizationId: selectedOrganizationId };
|
|
56
|
-
const hasAccess = await canAccessAssignableStaff(rbacService, actorId, scope);
|
|
57
|
-
if (!hasAccess) {
|
|
58
|
-
throw new CrudHttpError(
|
|
59
|
-
403,
|
|
60
|
-
{
|
|
61
|
-
error: translate(
|
|
62
|
-
"customers.assignableStaff.forbidden",
|
|
63
|
-
"Insufficient permissions to load assignable staff."
|
|
64
|
-
)
|
|
65
|
-
}
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
const normalizedSearch = query.search?.trim().toLowerCase() ?? "";
|
|
69
|
-
const members = await findWithDecryption(
|
|
70
|
-
em,
|
|
71
|
-
StaffTeamMember,
|
|
72
|
-
{
|
|
73
|
-
tenantId: auth.tenantId,
|
|
74
|
-
organizationId: selectedOrganizationId,
|
|
75
|
-
deletedAt: null,
|
|
76
|
-
isActive: true
|
|
77
|
-
},
|
|
78
|
-
{ orderBy: { displayName: "asc" } },
|
|
79
|
-
scope
|
|
80
|
-
);
|
|
81
|
-
const userIds = Array.from(
|
|
82
|
-
new Set(
|
|
83
|
-
members.map((member) => typeof member.userId === "string" && member.userId.trim().length > 0 ? member.userId : null).filter((value) => typeof value === "string")
|
|
84
|
-
)
|
|
85
|
-
);
|
|
86
|
-
const teamIds = Array.from(
|
|
87
|
-
new Set(
|
|
88
|
-
members.map((member) => typeof member.teamId === "string" && member.teamId.trim().length > 0 ? member.teamId : null).filter((value) => typeof value === "string")
|
|
89
|
-
)
|
|
90
|
-
);
|
|
91
|
-
const [users, teams] = await Promise.all([
|
|
92
|
-
userIds.length > 0 ? findWithDecryption(
|
|
93
|
-
em,
|
|
94
|
-
User,
|
|
95
|
-
{
|
|
96
|
-
id: { $in: userIds },
|
|
97
|
-
deletedAt: null,
|
|
98
|
-
tenantId: auth.tenantId,
|
|
99
|
-
organizationId: selectedOrganizationId
|
|
100
|
-
},
|
|
101
|
-
void 0,
|
|
102
|
-
scope
|
|
103
|
-
) : Promise.resolve([]),
|
|
104
|
-
teamIds.length > 0 ? findWithDecryption(
|
|
105
|
-
em,
|
|
106
|
-
StaffTeam,
|
|
107
|
-
{
|
|
108
|
-
id: { $in: teamIds },
|
|
109
|
-
deletedAt: null,
|
|
110
|
-
tenantId: auth.tenantId,
|
|
111
|
-
organizationId: selectedOrganizationId
|
|
112
|
-
},
|
|
113
|
-
void 0,
|
|
114
|
-
scope
|
|
115
|
-
) : Promise.resolve([])
|
|
116
|
-
]);
|
|
117
|
-
const userById = new Map(
|
|
118
|
-
users.map((user) => [
|
|
119
|
-
user.id,
|
|
120
|
-
{
|
|
121
|
-
id: user.id,
|
|
122
|
-
email: user.email ?? null
|
|
123
|
-
}
|
|
124
|
-
])
|
|
125
|
-
);
|
|
126
|
-
const teamById = new Map(
|
|
127
|
-
teams.map((team) => [
|
|
128
|
-
team.id,
|
|
129
|
-
{
|
|
130
|
-
id: team.id,
|
|
131
|
-
name: team.name ?? null
|
|
132
|
-
}
|
|
133
|
-
])
|
|
134
|
-
);
|
|
135
|
-
const items = members.filter((member) => typeof member.userId === "string" && member.userId.trim().length > 0).map((member) => {
|
|
136
|
-
const userId = member.userId;
|
|
137
|
-
const user = userById.get(userId) ?? { id: userId, email: null };
|
|
138
|
-
const team = member.teamId ? teamById.get(member.teamId) ?? null : null;
|
|
139
|
-
return {
|
|
140
|
-
id: member.id,
|
|
141
|
-
teamMemberId: member.id,
|
|
142
|
-
userId,
|
|
143
|
-
displayName: member.displayName?.trim() || user.email || userId,
|
|
144
|
-
email: user.email,
|
|
145
|
-
teamName: team?.name ?? null,
|
|
146
|
-
user,
|
|
147
|
-
team
|
|
148
|
-
};
|
|
149
|
-
}).filter((item) => {
|
|
150
|
-
if (!normalizedSearch) return true;
|
|
151
|
-
const haystack = [item.displayName, item.email, item.teamName].filter((value) => typeof value === "string" && value.length > 0).join(" ").toLowerCase();
|
|
152
|
-
return haystack.includes(normalizedSearch);
|
|
153
|
-
});
|
|
154
|
-
const deduped = Array.from(
|
|
155
|
-
items.reduce((acc, item) => {
|
|
156
|
-
if (!acc.has(item.userId)) {
|
|
157
|
-
acc.set(item.userId, item);
|
|
158
|
-
}
|
|
159
|
-
return acc;
|
|
160
|
-
}, /* @__PURE__ */ new Map())
|
|
161
|
-
).map(([, item]) => item);
|
|
162
|
-
const start = (query.page - 1) * query.pageSize;
|
|
163
|
-
return NextResponse.json({
|
|
164
|
-
items: deduped.slice(start, start + query.pageSize),
|
|
165
|
-
total: deduped.length,
|
|
166
|
-
page: query.page,
|
|
167
|
-
pageSize: query.pageSize
|
|
168
|
-
});
|
|
169
|
-
} catch (error) {
|
|
170
|
-
if (isCrudHttpError(error)) {
|
|
171
|
-
return NextResponse.json(error.body, { status: error.status });
|
|
172
|
-
}
|
|
173
|
-
if (error instanceof z.ZodError) {
|
|
174
|
-
return NextResponse.json({ error: translate("customers.errors.validationFailed", "Validation failed") }, { status: 400 });
|
|
175
|
-
}
|
|
176
|
-
console.error("customers.assignable-staff.get failed", error);
|
|
177
|
-
return NextResponse.json({ error: translate("customers.errors.assignable_staff_load_failed", "Failed to load assignable staff") }, { status: 500 });
|
|
178
|
-
}
|
|
30
|
+
const url = new URL(request.url);
|
|
31
|
+
const target = new URL("/api/staff/team-members/assignable", url.origin);
|
|
32
|
+
target.search = url.search;
|
|
33
|
+
return NextResponse.redirect(target, 308);
|
|
179
34
|
}
|
|
180
35
|
const openApi = {
|
|
181
36
|
tag: "Customers",
|
|
182
|
-
summary: "Assignable staff candidates",
|
|
37
|
+
summary: "Assignable staff candidates (DEPRECATED \u2014 redirects to /api/staff/team-members/assignable)",
|
|
183
38
|
methods: {
|
|
184
39
|
GET: {
|
|
185
|
-
|
|
40
|
+
deprecated: true,
|
|
41
|
+
summary: "DEPRECATED: use GET /api/staff/team-members/assignable instead.",
|
|
186
42
|
query: querySchema,
|
|
187
|
-
description: "Returns
|
|
43
|
+
description: "Deprecated. Returns 308 Permanent Redirect to /api/staff/team-members/assignable preserving the query string. Will be removed no earlier than the next major release.",
|
|
188
44
|
responses: [
|
|
189
45
|
{
|
|
190
46
|
status: 200,
|
|
191
|
-
description: "Assignable staff members",
|
|
47
|
+
description: "Assignable staff members (only reachable by following the redirect).",
|
|
192
48
|
schema: createPagedListResponseSchema(itemSchema)
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
status: 308,
|
|
52
|
+
description: "Permanent redirect to /api/staff/team-members/assignable.",
|
|
53
|
+
schema: errorSchema
|
|
193
54
|
}
|
|
194
55
|
],
|
|
195
56
|
errors: [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/customers/api/assignable-staff/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport {
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createPagedListResponseSchema } from '../openapi'\n\nconst querySchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(24),\n search: z.string().optional(),\n })\n .passthrough()\n\nconst itemSchema = z.object({\n id: z.string().uuid(),\n teamMemberId: z.string().uuid(),\n userId: z.string().uuid(),\n displayName: z.string(),\n email: z.string().nullable().optional(),\n teamName: z.string().nullable().optional(),\n user: z\n .object({\n id: z.string().uuid(),\n email: z.string().nullable().optional(),\n })\n .nullable()\n .optional(),\n team: z\n .object({\n id: z.string().uuid(),\n name: z.string().nullable().optional(),\n })\n .nullable()\n .optional(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.roles.view'] },\n}\n\nexport async function GET(request: Request) {\n const url = new URL(request.url)\n const target = new URL('/api/staff/team-members/assignable', url.origin)\n target.search = url.search\n return NextResponse.redirect(target, 308)\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Assignable staff candidates (DEPRECATED \u2014 redirects to /api/staff/team-members/assignable)',\n methods: {\n GET: {\n deprecated: true,\n summary: 'DEPRECATED: use GET /api/staff/team-members/assignable instead.',\n query: querySchema,\n description:\n 'Deprecated. Returns 308 Permanent Redirect to /api/staff/team-members/assignable preserving the query string. Will be removed no earlier than the next major release.',\n responses: [\n {\n status: 200,\n description: 'Assignable staff members (only reachable by following the redirect).',\n schema: createPagedListResponseSchema(itemSchema),\n },\n {\n status: 308,\n description: 'Permanent redirect to /api/staff/team-members/assignable.',\n schema: errorSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Invalid request', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 403, description: 'Forbidden', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,qCAAqC;AAE9C,MAAM,cAAc,EACjB,OAAO;AAAA,EACN,MAAM,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,QAAQ,CAAC;AAAA,EACxC,UAAU,EAAE,OAAO,OAAO,EAAE,IAAI,CAAC,EAAE,IAAI,GAAG,EAAE,QAAQ,EAAE;AAAA,EACtD,QAAQ,EAAE,OAAO,EAAE,SAAS;AAC9B,CAAC,EACA,YAAY;AAEf,MAAM,aAAa,EAAE,OAAO;AAAA,EAC1B,IAAI,EAAE,OAAO,EAAE,KAAK;AAAA,EACpB,cAAc,EAAE,OAAO,EAAE,KAAK;AAAA,EAC9B,QAAQ,EAAE,OAAO,EAAE,KAAK;AAAA,EACxB,aAAa,EAAE,OAAO;AAAA,EACtB,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACtC,UAAU,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACzC,MAAM,EACH,OAAO;AAAA,IACN,IAAI,EAAE,OAAO,EAAE,KAAK;AAAA,IACpB,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACxC,CAAC,EACA,SAAS,EACT,SAAS;AAAA,EACZ,MAAM,EACH,OAAO;AAAA,IACN,IAAI,EAAE,OAAO,EAAE,KAAK;AAAA,IACpB,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,SAAS;AAAA,EACvC,CAAC,EACA,SAAS,EACT,SAAS;AACd,CAAC;AAED,MAAM,cAAc,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC;AAE3C,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,sBAAsB,EAAE;AACtE;AAEA,eAAsB,IAAI,SAAkB;AAC1C,QAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,QAAM,SAAS,IAAI,IAAI,sCAAsC,IAAI,MAAM;AACvE,SAAO,SAAS,IAAI;AACpB,SAAO,aAAa,SAAS,QAAQ,GAAG;AAC1C;AAEO,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,KAAK;AAAA,MACH,YAAY;AAAA,MACZ,SAAS;AAAA,MACT,OAAO;AAAA,MACP,aACE;AAAA,MACF,WAAW;AAAA,QACT;AAAA,UACE,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,QAAQ,8BAA8B,UAAU;AAAA,QAClD;AAAA,QACA;AAAA,UACE,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,QAAQ;AAAA,QACV;AAAA,MACF;AAAA,MACA,QAAQ;AAAA,QACN,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,YAAY;AAAA,QACnE,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,YAAY;AAAA,QAChE,EAAE,QAAQ,KAAK,aAAa,aAAa,QAAQ,YAAY;AAAA,MAC/D;AAAA,IACF;AAAA,EACF;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -8,7 +8,7 @@ async function fetchAssignableStaffMembersPage(query, options) {
|
|
|
8
8
|
params.set("search", normalizedQuery);
|
|
9
9
|
}
|
|
10
10
|
const data = await readApiResultOrThrow(
|
|
11
|
-
`/api/
|
|
11
|
+
`/api/staff/team-members/assignable?${params.toString()}`,
|
|
12
12
|
options?.signal ? { signal: options.signal } : void 0
|
|
13
13
|
);
|
|
14
14
|
const rawItems = Array.isArray(data?.items) ? data.items : [];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/customers/components/detail/assignableStaff.ts"],
|
|
4
|
-
"sourcesContent": ["import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport type { FilterOption } from '@open-mercato/shared/lib/query/advanced-filter'\n\nexport type AssignableStaffMember = {\n teamMemberId: string\n userId: string\n displayName: string\n email: string | null\n teamName: string | null\n}\n\ntype AssignableStaffResponse = {\n items?: Array<Record<string, unknown>>\n total?: number\n page?: number\n pageSize?: number\n}\n\nexport type AssignableStaffMembersPage = {\n items: AssignableStaffMember[]\n total: number\n page: number\n pageSize: number\n}\n\nexport async function fetchAssignableStaffMembersPage(\n query: string,\n options?: { page?: number; pageSize?: number; signal?: AbortSignal },\n): Promise<AssignableStaffMembersPage> {\n const params = new URLSearchParams()\n params.set('page', String(options?.page ?? 1))\n params.set('pageSize', String(options?.pageSize ?? 24))\n const normalizedQuery = query.trim()\n if (normalizedQuery.length > 0) {\n params.set('search', normalizedQuery)\n }\n\n const data = await readApiResultOrThrow<AssignableStaffResponse>(\n `/api/
|
|
5
|
-
"mappings": "AAAA,SAAS,4BAA4B;AAyBrC,eAAsB,gCACpB,OACA,SACqC;AACrC,QAAM,SAAS,IAAI,gBAAgB;AACnC,SAAO,IAAI,QAAQ,OAAO,SAAS,QAAQ,CAAC,CAAC;AAC7C,SAAO,IAAI,YAAY,OAAO,SAAS,YAAY,EAAE,CAAC;AACtD,QAAM,kBAAkB,MAAM,KAAK;AACnC,MAAI,gBAAgB,SAAS,GAAG;AAC9B,WAAO,IAAI,UAAU,eAAe;AAAA,EACtC;AAEA,QAAM,OAAO,MAAM;AAAA,IACjB,
|
|
4
|
+
"sourcesContent": ["import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'\nimport type { FilterOption } from '@open-mercato/shared/lib/query/advanced-filter'\n\nexport type AssignableStaffMember = {\n teamMemberId: string\n userId: string\n displayName: string\n email: string | null\n teamName: string | null\n}\n\ntype AssignableStaffResponse = {\n items?: Array<Record<string, unknown>>\n total?: number\n page?: number\n pageSize?: number\n}\n\nexport type AssignableStaffMembersPage = {\n items: AssignableStaffMember[]\n total: number\n page: number\n pageSize: number\n}\n\nexport async function fetchAssignableStaffMembersPage(\n query: string,\n options?: { page?: number; pageSize?: number; signal?: AbortSignal },\n): Promise<AssignableStaffMembersPage> {\n const params = new URLSearchParams()\n params.set('page', String(options?.page ?? 1))\n params.set('pageSize', String(options?.pageSize ?? 24))\n const normalizedQuery = query.trim()\n if (normalizedQuery.length > 0) {\n params.set('search', normalizedQuery)\n }\n\n const data = await readApiResultOrThrow<AssignableStaffResponse>(\n `/api/staff/team-members/assignable?${params.toString()}`,\n options?.signal ? { signal: options.signal } : undefined,\n )\n\n const rawItems = Array.isArray(data?.items) ? data.items : []\n const deduped = new Map<string, AssignableStaffMember>()\n\n for (const item of rawItems) {\n const userId =\n typeof item?.userId === 'string'\n ? item.userId\n : typeof item?.user_id === 'string'\n ? item.user_id\n : null\n if (!userId || deduped.has(userId)) continue\n\n const user =\n item?.user && typeof item.user === 'object'\n ? (item.user as Record<string, unknown>)\n : null\n const team =\n item?.team && typeof item.team === 'object'\n ? (item.team as Record<string, unknown>)\n : null\n\n const displayName =\n typeof item?.displayName === 'string' && item.displayName.trim().length > 0\n ? item.displayName.trim()\n : typeof item?.display_name === 'string' && item.display_name.trim().length > 0\n ? item.display_name.trim()\n : null\n const email =\n user && typeof user.email === 'string' && user.email.trim().length > 0\n ? user.email.trim()\n : typeof item?.email === 'string' && item.email.trim().length > 0\n ? item.email.trim()\n : null\n const teamName =\n typeof item?.teamName === 'string' && item.teamName.trim().length > 0\n ? item.teamName.trim()\n : typeof item?.team_name === 'string' && item.team_name.trim().length > 0\n ? item.team_name.trim()\n : team && typeof team.name === 'string' && team.name.trim().length > 0\n ? team.name.trim()\n : null\n const teamMemberId =\n typeof item?.teamMemberId === 'string'\n ? item.teamMemberId\n : typeof item?.team_member_id === 'string'\n ? item.team_member_id\n : typeof item?.id === 'string'\n ? item.id\n : userId\n\n deduped.set(userId, {\n teamMemberId,\n userId,\n displayName: displayName ?? email ?? userId,\n email,\n teamName,\n })\n }\n\n return {\n items: Array.from(deduped.values()),\n total:\n typeof data?.total === 'number' && Number.isFinite(data.total)\n ? data.total\n : deduped.size,\n page:\n typeof data?.page === 'number' && Number.isFinite(data.page)\n ? data.page\n : options?.page ?? 1,\n pageSize:\n typeof data?.pageSize === 'number' && Number.isFinite(data.pageSize)\n ? data.pageSize\n : options?.pageSize ?? 24,\n }\n}\n\nexport async function fetchAssignableStaffMembers(\n query: string,\n options?: { pageSize?: number; signal?: AbortSignal },\n): Promise<AssignableStaffMember[]> {\n const result = await fetchAssignableStaffMembersPage(query, options)\n return result.items\n}\n\nexport function mapAssignableStaffToFilterOptions(items: AssignableStaffMember[]): FilterOption[] {\n return items.map((item) => ({\n value: item.userId,\n label: item.email && item.email !== item.displayName\n ? `${item.displayName} (${item.email})`\n : item.displayName,\n tone: 'neutral',\n }))\n}\n\nexport function ensureCurrentUserFilterOption(\n options: FilterOption[],\n currentUserId: string,\n fallbackLabel: string,\n): FilterOption[] {\n const trimmed = currentUserId.trim()\n if (!trimmed || options.some((option) => option.value === trimmed)) return options\n return [{ value: trimmed, label: fallbackLabel, tone: 'neutral' }, ...options]\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,71 +1,38 @@
|
|
|
1
1
|
import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
|
|
2
|
-
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
3
|
-
import { StaffTeamMember } from "@open-mercato/core/modules/staff/data/entities";
|
|
4
|
-
const MANAGE_AVAILABILITY_FEATURE = "planner.manage_availability";
|
|
5
|
-
const SELF_MANAGE_FEATURE = "staff.my_availability.manage";
|
|
6
|
-
const SELF_UNAVAILABILITY_FEATURE = "staff.my_availability.unavailability";
|
|
7
2
|
function buildForbiddenError(translate) {
|
|
8
|
-
return new CrudHttpError(403, {
|
|
3
|
+
return new CrudHttpError(403, {
|
|
4
|
+
error: translate("planner.availability.errors.unauthorized", "Unauthorized")
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
function buildStaffModuleNotLoadedError() {
|
|
8
|
+
return new CrudHttpError(403, { error: "staff_module_not_loaded" });
|
|
9
9
|
}
|
|
10
10
|
async function resolveAvailabilityWriteAccess(ctx) {
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
organizationId
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
const rbac = ctx.container.resolve("rbacService");
|
|
25
|
-
const canManageAll = await rbac.userHasAllFeatures(auth.sub, [MANAGE_AVAILABILITY_FEATURE], { tenantId, organizationId });
|
|
26
|
-
if (canManageAll) {
|
|
27
|
-
return {
|
|
28
|
-
canManageAll: true,
|
|
29
|
-
canManageSelf: true,
|
|
30
|
-
canManageUnavailability: true,
|
|
31
|
-
memberId: null,
|
|
32
|
-
tenantId,
|
|
33
|
-
organizationId
|
|
34
|
-
};
|
|
35
|
-
}
|
|
36
|
-
const [canManageSelf, canManageUnavailability] = await Promise.all([
|
|
37
|
-
rbac.userHasAllFeatures(auth.sub, [SELF_MANAGE_FEATURE], { tenantId, organizationId }),
|
|
38
|
-
rbac.userHasAllFeatures(auth.sub, [SELF_UNAVAILABILITY_FEATURE], { tenantId, organizationId })
|
|
39
|
-
]);
|
|
40
|
-
if (!canManageSelf) {
|
|
11
|
+
const tenantId = ctx.auth?.tenantId ?? null;
|
|
12
|
+
const organizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null;
|
|
13
|
+
const resolver = ctx.container.resolve(
|
|
14
|
+
"availabilityAccessResolver",
|
|
15
|
+
{ allowUnregistered: true }
|
|
16
|
+
);
|
|
17
|
+
if (!resolver) {
|
|
18
|
+
console.warn(
|
|
19
|
+
"[planner] staff_module_not_loaded \u2014 availabilityAccessResolver unregistered; denying availability write access"
|
|
20
|
+
);
|
|
41
21
|
return {
|
|
42
22
|
canManageAll: false,
|
|
43
23
|
canManageSelf: false,
|
|
44
24
|
canManageUnavailability: false,
|
|
45
25
|
memberId: null,
|
|
46
26
|
tenantId,
|
|
47
|
-
organizationId
|
|
27
|
+
organizationId,
|
|
28
|
+
unregistered: true
|
|
48
29
|
};
|
|
49
30
|
}
|
|
50
|
-
|
|
51
|
-
const member = await findOneWithDecryption(
|
|
52
|
-
em,
|
|
53
|
-
StaffTeamMember,
|
|
54
|
-
{ userId: auth.sub, deletedAt: null },
|
|
55
|
-
void 0,
|
|
56
|
-
{ tenantId, organizationId }
|
|
57
|
-
);
|
|
58
|
-
return {
|
|
59
|
-
canManageAll: false,
|
|
60
|
-
canManageSelf,
|
|
61
|
-
canManageUnavailability,
|
|
62
|
-
memberId: member?.id ?? null,
|
|
63
|
-
tenantId,
|
|
64
|
-
organizationId
|
|
65
|
-
};
|
|
31
|
+
return resolver.resolveAvailabilityWriteAccess(ctx);
|
|
66
32
|
}
|
|
67
33
|
async function assertAvailabilityWriteAccess(ctx, params, translate) {
|
|
68
34
|
const access = await resolveAvailabilityWriteAccess(ctx);
|
|
35
|
+
if (access.unregistered) throw buildStaffModuleNotLoadedError();
|
|
69
36
|
if (access.canManageAll) return access;
|
|
70
37
|
if (!access.canManageSelf) throw buildForbiddenError(translate);
|
|
71
38
|
if (!access.memberId || params.subjectType !== "member" || params.subjectId !== access.memberId) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/planner/api/access.ts"],
|
|
4
|
-
"sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\n\ntype TranslateFn = (key: string, fallback?: string) => string\n\nexport type AvailabilityAccessContext = {\n container: AwilixContainer\n auth: AuthContext | null\n selectedOrganizationId?: string | null\n}\n\nexport type AvailabilityWriteAccess = {\n canManageAll: boolean\n canManageSelf: boolean\n canManageUnavailability: boolean\n memberId: string | null\n tenantId: string | null\n organizationId: string | null\n unregistered?: boolean\n}\n\ntype AvailabilityAccessResolver = {\n resolveAvailabilityWriteAccess(\n ctx: AvailabilityAccessContext,\n ): Promise<AvailabilityWriteAccess>\n}\n\nfunction buildForbiddenError(translate: TranslateFn) {\n return new CrudHttpError(403, {\n error: translate('planner.availability.errors.unauthorized', 'Unauthorized'),\n })\n}\n\nfunction buildStaffModuleNotLoadedError() {\n return new CrudHttpError(403, { error: 'staff_module_not_loaded' })\n}\n\nexport async function resolveAvailabilityWriteAccess(\n ctx: AvailabilityAccessContext,\n): Promise<AvailabilityWriteAccess> {\n const tenantId = ctx.auth?.tenantId ?? null\n const organizationId = ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null\n const resolver = ctx.container.resolve<AvailabilityAccessResolver | undefined>(\n 'availabilityAccessResolver',\n { allowUnregistered: true },\n )\n if (!resolver) {\n console.warn(\n '[planner] staff_module_not_loaded \u2014 availabilityAccessResolver unregistered; denying availability write access',\n )\n return {\n canManageAll: false,\n canManageSelf: false,\n canManageUnavailability: false,\n memberId: null,\n tenantId,\n organizationId,\n unregistered: true,\n }\n }\n return resolver.resolveAvailabilityWriteAccess(ctx)\n}\n\nexport async function assertAvailabilityWriteAccess(\n ctx: AvailabilityAccessContext,\n params: { subjectType: string; subjectId: string; requiresUnavailability?: boolean },\n translate: TranslateFn,\n): Promise<AvailabilityWriteAccess> {\n const access = await resolveAvailabilityWriteAccess(ctx)\n if (access.unregistered) throw buildStaffModuleNotLoadedError()\n if (access.canManageAll) return access\n if (!access.canManageSelf) throw buildForbiddenError(translate)\n if (!access.memberId || params.subjectType !== 'member' || params.subjectId !== access.memberId) {\n throw buildForbiddenError(translate)\n }\n if (params.requiresUnavailability && !access.canManageUnavailability) {\n throw buildForbiddenError(translate)\n }\n return access\n}\n"],
|
|
5
|
+
"mappings": "AAEA,SAAS,qBAAqB;AA0B9B,SAAS,oBAAoB,WAAwB;AACnD,SAAO,IAAI,cAAc,KAAK;AAAA,IAC5B,OAAO,UAAU,4CAA4C,cAAc;AAAA,EAC7E,CAAC;AACH;AAEA,SAAS,iCAAiC;AACxC,SAAO,IAAI,cAAc,KAAK,EAAE,OAAO,0BAA0B,CAAC;AACpE;AAEA,eAAsB,+BACpB,KACkC;AAClC,QAAM,WAAW,IAAI,MAAM,YAAY;AACvC,QAAM,iBAAiB,IAAI,0BAA0B,IAAI,MAAM,SAAS;AACxE,QAAM,WAAW,IAAI,UAAU;AAAA,IAC7B;AAAA,IACA,EAAE,mBAAmB,KAAK;AAAA,EAC5B;AACA,MAAI,CAAC,UAAU;AACb,YAAQ;AAAA,MACN;AAAA,IACF;AACA,WAAO;AAAA,MACL,cAAc;AAAA,MACd,eAAe;AAAA,MACf,yBAAyB;AAAA,MACzB,UAAU;AAAA,MACV;AAAA,MACA;AAAA,MACA,cAAc;AAAA,IAChB;AAAA,EACF;AACA,SAAO,SAAS,+BAA+B,GAAG;AACpD;AAEA,eAAsB,8BACpB,KACA,QACA,WACkC;AAClC,QAAM,SAAS,MAAM,+BAA+B,GAAG;AACvD,MAAI,OAAO,aAAc,OAAM,+BAA+B;AAC9D,MAAI,OAAO,aAAc,QAAO;AAChC,MAAI,CAAC,OAAO,cAAe,OAAM,oBAAoB,SAAS;AAC9D,MAAI,CAAC,OAAO,YAAY,OAAO,gBAAgB,YAAY,OAAO,cAAc,OAAO,UAAU;AAC/F,UAAM,oBAAoB,SAAS;AAAA,EACrC;AACA,MAAI,OAAO,0BAA0B,CAAC,OAAO,yBAAyB;AACpE,UAAM,oBAAoB,SAAS;AAAA,EACrC;AACA,SAAO;AACT;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|