@open-mercato/core 0.6.3-develop.3811.1.be22750402 → 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/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/customers/api/assignable-staff/route.ts +14 -185
- package/src/modules/customers/components/detail/assignableStaff.ts +1 -1
- package/src/modules/planner/api/access.ts +29 -55
- 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/.turbo/turbo-build.log
CHANGED
|
@@ -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
|
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
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 { createPagedListResponseSchema as createSharedPagedListResponseSchema } from "@open-mercato/shared/lib/openapi/crud";
|
|
7
|
+
import { User } from "@open-mercato/core/modules/auth/data/entities";
|
|
8
|
+
import {
|
|
9
|
+
resolveAuthActorId,
|
|
10
|
+
resolveCustomersRequestContext
|
|
11
|
+
} from "@open-mercato/core/modules/customers/lib/interactionRequestContext";
|
|
12
|
+
import { StaffTeam, StaffTeamMember } from "../../../data/entities.js";
|
|
13
|
+
const querySchema = z.object({
|
|
14
|
+
page: z.coerce.number().min(1).default(1),
|
|
15
|
+
pageSize: z.coerce.number().min(1).max(100).default(24),
|
|
16
|
+
search: z.string().optional()
|
|
17
|
+
}).passthrough();
|
|
18
|
+
const itemSchema = z.object({
|
|
19
|
+
id: z.string().uuid(),
|
|
20
|
+
teamMemberId: z.string().uuid(),
|
|
21
|
+
userId: z.string().uuid(),
|
|
22
|
+
displayName: z.string(),
|
|
23
|
+
email: z.string().nullable().optional(),
|
|
24
|
+
teamName: z.string().nullable().optional(),
|
|
25
|
+
user: z.object({
|
|
26
|
+
id: z.string().uuid(),
|
|
27
|
+
email: z.string().nullable().optional()
|
|
28
|
+
}).nullable().optional(),
|
|
29
|
+
team: z.object({
|
|
30
|
+
id: z.string().uuid(),
|
|
31
|
+
name: z.string().nullable().optional()
|
|
32
|
+
}).nullable().optional()
|
|
33
|
+
});
|
|
34
|
+
const errorSchema = z.object({ error: z.string() });
|
|
35
|
+
const pagedListSchema = createSharedPagedListResponseSchema(itemSchema, {
|
|
36
|
+
paginationMetaOptional: true
|
|
37
|
+
});
|
|
38
|
+
const metadata = {
|
|
39
|
+
GET: { requireAuth: true, requireFeatures: ["customers.roles.view"] }
|
|
40
|
+
};
|
|
41
|
+
async function canAccessAssignableStaff(rbac, userId, scope) {
|
|
42
|
+
if (!rbac) return false;
|
|
43
|
+
if (await rbac.userHasAllFeatures(userId, ["customers.roles.manage"], scope)) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
return rbac.userHasAllFeatures(userId, ["customers.activities.manage"], scope);
|
|
47
|
+
}
|
|
48
|
+
async function GET(request) {
|
|
49
|
+
const { translate } = await resolveTranslations();
|
|
50
|
+
try {
|
|
51
|
+
const query = querySchema.parse(Object.fromEntries(new URL(request.url).searchParams));
|
|
52
|
+
const { container, em, auth, selectedOrganizationId } = await resolveCustomersRequestContext(request);
|
|
53
|
+
if (!selectedOrganizationId) {
|
|
54
|
+
throw new CrudHttpError(
|
|
55
|
+
400,
|
|
56
|
+
{ error: translate("customers.errors.organization_required", "Organization context is required") }
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const actorId = resolveAuthActorId(auth);
|
|
60
|
+
const rbacService = container.resolve("rbacService");
|
|
61
|
+
const scope = { tenantId: auth.tenantId, organizationId: selectedOrganizationId };
|
|
62
|
+
const hasAccess = await canAccessAssignableStaff(rbacService, actorId, scope);
|
|
63
|
+
if (!hasAccess) {
|
|
64
|
+
throw new CrudHttpError(
|
|
65
|
+
403,
|
|
66
|
+
{
|
|
67
|
+
error: translate(
|
|
68
|
+
"customers.assignableStaff.forbidden",
|
|
69
|
+
"Insufficient permissions to load assignable staff."
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const normalizedSearch = query.search?.trim().toLowerCase() ?? "";
|
|
75
|
+
const members = await findWithDecryption(
|
|
76
|
+
em,
|
|
77
|
+
StaffTeamMember,
|
|
78
|
+
{
|
|
79
|
+
tenantId: auth.tenantId,
|
|
80
|
+
organizationId: selectedOrganizationId,
|
|
81
|
+
deletedAt: null,
|
|
82
|
+
isActive: true
|
|
83
|
+
},
|
|
84
|
+
{ orderBy: { displayName: "asc" } },
|
|
85
|
+
scope
|
|
86
|
+
);
|
|
87
|
+
const userIds = Array.from(
|
|
88
|
+
new Set(
|
|
89
|
+
members.map((member) => typeof member.userId === "string" && member.userId.trim().length > 0 ? member.userId : null).filter((value) => typeof value === "string")
|
|
90
|
+
)
|
|
91
|
+
);
|
|
92
|
+
const teamIds = Array.from(
|
|
93
|
+
new Set(
|
|
94
|
+
members.map((member) => typeof member.teamId === "string" && member.teamId.trim().length > 0 ? member.teamId : null).filter((value) => typeof value === "string")
|
|
95
|
+
)
|
|
96
|
+
);
|
|
97
|
+
const [users, teams] = await Promise.all([
|
|
98
|
+
userIds.length > 0 ? findWithDecryption(
|
|
99
|
+
em,
|
|
100
|
+
User,
|
|
101
|
+
{
|
|
102
|
+
id: { $in: userIds },
|
|
103
|
+
deletedAt: null,
|
|
104
|
+
tenantId: auth.tenantId,
|
|
105
|
+
organizationId: selectedOrganizationId
|
|
106
|
+
},
|
|
107
|
+
void 0,
|
|
108
|
+
scope
|
|
109
|
+
) : Promise.resolve([]),
|
|
110
|
+
teamIds.length > 0 ? findWithDecryption(
|
|
111
|
+
em,
|
|
112
|
+
StaffTeam,
|
|
113
|
+
{
|
|
114
|
+
id: { $in: teamIds },
|
|
115
|
+
deletedAt: null,
|
|
116
|
+
tenantId: auth.tenantId,
|
|
117
|
+
organizationId: selectedOrganizationId
|
|
118
|
+
},
|
|
119
|
+
void 0,
|
|
120
|
+
scope
|
|
121
|
+
) : Promise.resolve([])
|
|
122
|
+
]);
|
|
123
|
+
const userById = new Map(
|
|
124
|
+
users.map((user) => [
|
|
125
|
+
user.id,
|
|
126
|
+
{
|
|
127
|
+
id: user.id,
|
|
128
|
+
email: user.email ?? null
|
|
129
|
+
}
|
|
130
|
+
])
|
|
131
|
+
);
|
|
132
|
+
const teamById = new Map(
|
|
133
|
+
teams.map((team) => [
|
|
134
|
+
team.id,
|
|
135
|
+
{
|
|
136
|
+
id: team.id,
|
|
137
|
+
name: team.name ?? null
|
|
138
|
+
}
|
|
139
|
+
])
|
|
140
|
+
);
|
|
141
|
+
const items = members.filter((member) => typeof member.userId === "string" && member.userId.trim().length > 0).map((member) => {
|
|
142
|
+
const userId = member.userId;
|
|
143
|
+
const user = userById.get(userId) ?? { id: userId, email: null };
|
|
144
|
+
const team = member.teamId ? teamById.get(member.teamId) ?? null : null;
|
|
145
|
+
return {
|
|
146
|
+
id: member.id,
|
|
147
|
+
teamMemberId: member.id,
|
|
148
|
+
userId,
|
|
149
|
+
displayName: member.displayName?.trim() || user.email || userId,
|
|
150
|
+
email: user.email,
|
|
151
|
+
teamName: team?.name ?? null,
|
|
152
|
+
user,
|
|
153
|
+
team
|
|
154
|
+
};
|
|
155
|
+
}).filter((item) => {
|
|
156
|
+
if (!normalizedSearch) return true;
|
|
157
|
+
const haystack = [item.displayName, item.email, item.teamName].filter((value) => typeof value === "string" && value.length > 0).join(" ").toLowerCase();
|
|
158
|
+
return haystack.includes(normalizedSearch);
|
|
159
|
+
});
|
|
160
|
+
const deduped = Array.from(
|
|
161
|
+
items.reduce((acc, item) => {
|
|
162
|
+
if (!acc.has(item.userId)) {
|
|
163
|
+
acc.set(item.userId, item);
|
|
164
|
+
}
|
|
165
|
+
return acc;
|
|
166
|
+
}, /* @__PURE__ */ new Map())
|
|
167
|
+
).map(([, item]) => item);
|
|
168
|
+
const start = (query.page - 1) * query.pageSize;
|
|
169
|
+
return NextResponse.json({
|
|
170
|
+
items: deduped.slice(start, start + query.pageSize),
|
|
171
|
+
total: deduped.length,
|
|
172
|
+
page: query.page,
|
|
173
|
+
pageSize: query.pageSize
|
|
174
|
+
});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (isCrudHttpError(error)) {
|
|
177
|
+
return NextResponse.json(error.body, { status: error.status });
|
|
178
|
+
}
|
|
179
|
+
if (error instanceof z.ZodError) {
|
|
180
|
+
return NextResponse.json({ error: translate("customers.errors.validationFailed", "Validation failed") }, { status: 400 });
|
|
181
|
+
}
|
|
182
|
+
console.error("staff.assignable-team-members.get failed", error);
|
|
183
|
+
return NextResponse.json({ error: translate("customers.errors.assignable_staff_load_failed", "Failed to load assignable staff") }, { status: 500 });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const openApi = {
|
|
187
|
+
tag: "Staff",
|
|
188
|
+
summary: "Assignable staff candidates",
|
|
189
|
+
methods: {
|
|
190
|
+
GET: {
|
|
191
|
+
summary: "List staff members that can be assigned from customer flows",
|
|
192
|
+
query: querySchema,
|
|
193
|
+
description: "Returns active staff members linked to auth users. Access requires either customers.roles.manage or customers.activities.manage. Owned by the staff module; consumed from customer flows via this canonical URL. Replaces the deprecated /api/customers/assignable-staff route.",
|
|
194
|
+
responses: [
|
|
195
|
+
{
|
|
196
|
+
status: 200,
|
|
197
|
+
description: "Assignable staff members",
|
|
198
|
+
schema: pagedListSchema
|
|
199
|
+
}
|
|
200
|
+
],
|
|
201
|
+
errors: [
|
|
202
|
+
{ status: 400, description: "Invalid request", schema: errorSchema },
|
|
203
|
+
{ status: 401, description: "Unauthorized", schema: errorSchema },
|
|
204
|
+
{ status: 403, description: "Forbidden", schema: errorSchema }
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
export {
|
|
210
|
+
GET,
|
|
211
|
+
metadata,
|
|
212
|
+
openApi
|
|
213
|
+
};
|
|
214
|
+
//# sourceMappingURL=route.js.map
|