@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.
Files changed (32) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +37 -1
  3. package/dist/modules/customers/api/assignable-staff/route.js +14 -153
  4. package/dist/modules/customers/api/assignable-staff/route.js.map +2 -2
  5. package/dist/modules/customers/components/detail/assignableStaff.js +1 -1
  6. package/dist/modules/customers/components/detail/assignableStaff.js.map +2 -2
  7. package/dist/modules/planner/api/access.js +20 -53
  8. package/dist/modules/planner/api/access.js.map +2 -2
  9. package/dist/modules/staff/api/team-members/assignable/route.js +214 -0
  10. package/dist/modules/staff/api/team-members/assignable/route.js.map +7 -0
  11. package/dist/modules/staff/di.js +14 -0
  12. package/dist/modules/staff/di.js.map +7 -0
  13. package/dist/modules/staff/lib/availabilityAccess.js +73 -0
  14. package/dist/modules/staff/lib/availabilityAccess.js.map +7 -0
  15. package/package.json +7 -7
  16. package/src/modules/auth/AGENTS.md +27 -0
  17. package/src/modules/catalog/AGENTS.md +21 -2
  18. package/src/modules/currencies/AGENTS.md +21 -2
  19. package/src/modules/customer_accounts/AGENTS.md +26 -6
  20. package/src/modules/customers/AGENTS.md +20 -1
  21. package/src/modules/customers/api/assignable-staff/route.ts +14 -185
  22. package/src/modules/customers/components/detail/assignableStaff.ts +1 -1
  23. package/src/modules/data_sync/AGENTS.md +30 -11
  24. package/src/modules/integrations/AGENTS.md +31 -13
  25. package/src/modules/planner/api/access.ts +29 -55
  26. package/src/modules/progress/AGENTS.md +20 -2
  27. package/src/modules/sales/AGENTS.md +24 -5
  28. package/src/modules/staff/AGENTS.md +78 -0
  29. package/src/modules/staff/api/team-members/assignable/route.ts +257 -0
  30. package/src/modules/staff/di.ts +20 -0
  31. package/src/modules/staff/lib/availabilityAccess.ts +90 -0
  32. package/src/modules/workflows/AGENTS.md +25 -3
@@ -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
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../../src/modules/staff/api/team-members/assignable/route.ts"],
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { createPagedListResponseSchema as createSharedPagedListResponseSchema } from '@open-mercato/shared/lib/openapi/crud'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport {\n resolveAuthActorId,\n resolveCustomersRequestContext,\n} from '@open-mercato/core/modules/customers/lib/interactionRequestContext'\nimport { StaffTeam, StaffTeamMember } from '../../../data/entities'\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\nconst pagedListSchema = createSharedPagedListResponseSchema(itemSchema, {\n paginationMetaOptional: true,\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.roles.view'] },\n}\n\nasync function canAccessAssignableStaff(\n rbac: RbacService | undefined,\n userId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<boolean> {\n if (!rbac) return false\n if (\n await rbac.userHasAllFeatures(userId, ['customers.roles.manage'], scope)\n ) {\n return true\n }\n return rbac.userHasAllFeatures(userId, ['customers.activities.manage'], scope)\n}\n\nexport async function GET(request: Request) {\n const { translate } = await resolveTranslations()\n try {\n const query = querySchema.parse(Object.fromEntries(new URL(request.url).searchParams))\n const { container, em, auth, selectedOrganizationId } = await resolveCustomersRequestContext(request)\n\n if (!selectedOrganizationId) {\n throw new CrudHttpError(\n 400,\n { error: translate('customers.errors.organization_required', 'Organization context is required') },\n )\n }\n\n const actorId = resolveAuthActorId(auth)\n const rbacService = container.resolve('rbacService') as RbacService | undefined\n const scope = { tenantId: auth.tenantId, organizationId: selectedOrganizationId }\n const hasAccess = await canAccessAssignableStaff(rbacService, actorId, scope)\n if (!hasAccess) {\n throw new CrudHttpError(\n 403,\n {\n error: translate(\n 'customers.assignableStaff.forbidden',\n 'Insufficient permissions to load assignable staff.',\n ),\n },\n )\n }\n\n const normalizedSearch = query.search?.trim().toLowerCase() ?? ''\n\n const members = await findWithDecryption(\n em,\n StaffTeamMember,\n {\n tenantId: auth.tenantId,\n organizationId: selectedOrganizationId,\n deletedAt: null,\n isActive: true,\n },\n { orderBy: { displayName: 'asc' } },\n scope,\n )\n\n const userIds = Array.from(\n new Set(\n members\n .map((member) => (typeof member.userId === 'string' && member.userId.trim().length > 0 ? member.userId : null))\n .filter((value): value is string => typeof value === 'string'),\n ),\n )\n const teamIds = Array.from(\n new Set(\n members\n .map((member) => (typeof member.teamId === 'string' && member.teamId.trim().length > 0 ? member.teamId : null))\n .filter((value): value is string => typeof value === 'string'),\n ),\n )\n\n const [users, teams] = await Promise.all([\n userIds.length > 0\n ? findWithDecryption(\n em,\n User,\n {\n id: { $in: userIds },\n deletedAt: null,\n tenantId: auth.tenantId,\n organizationId: selectedOrganizationId,\n },\n undefined,\n scope,\n )\n : Promise.resolve([]),\n teamIds.length > 0\n ? findWithDecryption(\n em,\n StaffTeam,\n {\n id: { $in: teamIds },\n deletedAt: null,\n tenantId: auth.tenantId,\n organizationId: selectedOrganizationId,\n },\n undefined,\n scope,\n )\n : Promise.resolve([]),\n ])\n\n const userById = new Map(\n users.map((user) => [\n user.id,\n {\n id: user.id,\n email: user.email ?? null,\n },\n ]),\n )\n const teamById = new Map(\n teams.map((team) => [\n team.id,\n {\n id: team.id,\n name: team.name ?? null,\n },\n ]),\n )\n\n const items = members\n .filter((member) => typeof member.userId === 'string' && member.userId.trim().length > 0)\n .map((member) => {\n const userId = member.userId as string\n const user = userById.get(userId) ?? { id: userId, email: null }\n const team = member.teamId ? teamById.get(member.teamId) ?? null : null\n return {\n id: member.id,\n teamMemberId: member.id,\n userId,\n displayName: member.displayName?.trim() || user.email || userId,\n email: user.email,\n teamName: team?.name ?? null,\n user,\n team,\n }\n })\n .filter((item) => {\n if (!normalizedSearch) return true\n const haystack = [item.displayName, item.email, item.teamName]\n .filter((value): value is string => typeof value === 'string' && value.length > 0)\n .join(' ')\n .toLowerCase()\n return haystack.includes(normalizedSearch)\n })\n\n const deduped = Array.from(\n items.reduce((acc, item) => {\n if (!acc.has(item.userId)) {\n acc.set(item.userId, item)\n }\n return acc\n }, new Map<string, (typeof items)[number]>()),\n ).map(([, item]) => item)\n\n const start = (query.page - 1) * query.pageSize\n return NextResponse.json({\n items: deduped.slice(start, start + query.pageSize),\n total: deduped.length,\n page: query.page,\n pageSize: query.pageSize,\n })\n } catch (error) {\n if (isCrudHttpError(error)) {\n return NextResponse.json(error.body, { status: error.status })\n }\n if (error instanceof z.ZodError) {\n return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed') }, { status: 400 })\n }\n console.error('staff.assignable-team-members.get failed', error)\n return NextResponse.json({ error: translate('customers.errors.assignable_staff_load_failed', 'Failed to load assignable staff') }, { status: 500 })\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Staff',\n summary: 'Assignable staff candidates',\n methods: {\n GET: {\n summary: 'List staff members that can be assigned from customer flows',\n query: querySchema,\n description:\n '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.',\n responses: [\n {\n status: 200,\n description: 'Assignable staff members',\n schema: pagedListSchema,\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": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,14 @@
1
+ import { asValue } from "awilix";
2
+ import {
3
+ resolveAvailabilityWriteAccess
4
+ } from "./lib/availabilityAccess.js";
5
+ function register(container) {
6
+ const resolver = { resolveAvailabilityWriteAccess };
7
+ container.register({
8
+ availabilityAccessResolver: asValue(resolver)
9
+ });
10
+ }
11
+ export {
12
+ register
13
+ };
14
+ //# sourceMappingURL=di.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../src/modules/staff/di.ts"],
4
+ "sourcesContent": ["import { asValue } from 'awilix'\nimport type { AppContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n resolveAvailabilityWriteAccess,\n type AvailabilityAccessContext,\n type AvailabilityWriteAccess,\n} from './lib/availabilityAccess'\n\nexport type AvailabilityAccessResolver = {\n resolveAvailabilityWriteAccess(\n ctx: AvailabilityAccessContext,\n ): Promise<AvailabilityWriteAccess>\n}\n\nexport function register(container: AppContainer) {\n const resolver: AvailabilityAccessResolver = { resolveAvailabilityWriteAccess }\n container.register({\n availabilityAccessResolver: asValue(resolver),\n })\n}\n"],
5
+ "mappings": "AAAA,SAAS,eAAe;AAExB;AAAA,EACE;AAAA,OAGK;AAQA,SAAS,SAAS,WAAyB;AAChD,QAAM,WAAuC,EAAE,+BAA+B;AAC9E,YAAU,SAAS;AAAA,IACjB,4BAA4B,QAAQ,QAAQ;AAAA,EAC9C,CAAC;AACH;",
6
+ "names": []
7
+ }
@@ -0,0 +1,73 @@
1
+ import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
2
+ import { StaffTeamMember } from "../data/entities.js";
3
+ const MANAGE_AVAILABILITY_FEATURE = "planner.manage_availability";
4
+ const SELF_MANAGE_FEATURE = "staff.my_availability.manage";
5
+ const SELF_UNAVAILABILITY_FEATURE = "staff.my_availability.unavailability";
6
+ async function resolveAvailabilityWriteAccess(ctx) {
7
+ const auth = ctx.auth;
8
+ const tenantId = auth?.tenantId ?? null;
9
+ const organizationId = ctx.selectedOrganizationId ?? auth?.orgId ?? null;
10
+ if (!auth || !auth.sub || auth.isApiKey) {
11
+ return {
12
+ canManageAll: false,
13
+ canManageSelf: false,
14
+ canManageUnavailability: false,
15
+ memberId: null,
16
+ tenantId,
17
+ organizationId
18
+ };
19
+ }
20
+ const rbac = ctx.container.resolve("rbacService");
21
+ const canManageAll = await rbac.userHasAllFeatures(
22
+ auth.sub,
23
+ [MANAGE_AVAILABILITY_FEATURE],
24
+ { tenantId, organizationId }
25
+ );
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) {
41
+ return {
42
+ canManageAll: false,
43
+ canManageSelf: false,
44
+ canManageUnavailability: false,
45
+ memberId: null,
46
+ tenantId,
47
+ organizationId
48
+ };
49
+ }
50
+ const em = ctx.container.resolve("em");
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
+ };
66
+ }
67
+ export {
68
+ MANAGE_AVAILABILITY_FEATURE,
69
+ SELF_MANAGE_FEATURE,
70
+ SELF_UNAVAILABILITY_FEATURE,
71
+ resolveAvailabilityWriteAccess
72
+ };
73
+ //# sourceMappingURL=availabilityAccess.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/staff/lib/availabilityAccess.ts"],
4
+ "sourcesContent": ["import type { AwilixContainer } from 'awilix'\nimport type { AuthContext } from '@open-mercato/shared/lib/auth/server'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { StaffTeamMember } from '../data/entities'\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\nexport const MANAGE_AVAILABILITY_FEATURE = 'planner.manage_availability'\nexport const SELF_MANAGE_FEATURE = 'staff.my_availability.manage'\nexport const SELF_UNAVAILABILITY_FEATURE = 'staff.my_availability.unavailability'\n\nexport async function resolveAvailabilityWriteAccess(\n ctx: AvailabilityAccessContext,\n): Promise<AvailabilityWriteAccess> {\n const auth = ctx.auth\n const tenantId = auth?.tenantId ?? null\n const organizationId = ctx.selectedOrganizationId ?? auth?.orgId ?? null\n if (!auth || !auth.sub || auth.isApiKey) {\n return {\n canManageAll: false,\n canManageSelf: false,\n canManageUnavailability: false,\n memberId: null,\n tenantId,\n organizationId,\n }\n }\n const rbac = ctx.container.resolve('rbacService') as RbacService\n const canManageAll = await rbac.userHasAllFeatures(\n auth.sub,\n [MANAGE_AVAILABILITY_FEATURE],\n { tenantId, organizationId },\n )\n if (canManageAll) {\n return {\n canManageAll: true,\n canManageSelf: true,\n canManageUnavailability: true,\n memberId: null,\n tenantId,\n organizationId,\n }\n }\n const [canManageSelf, canManageUnavailability] = await Promise.all([\n rbac.userHasAllFeatures(auth.sub, [SELF_MANAGE_FEATURE], { tenantId, organizationId }),\n rbac.userHasAllFeatures(auth.sub, [SELF_UNAVAILABILITY_FEATURE], { tenantId, organizationId }),\n ])\n if (!canManageSelf) {\n return {\n canManageAll: false,\n canManageSelf: false,\n canManageUnavailability: false,\n memberId: null,\n tenantId,\n organizationId,\n }\n }\n const em = ctx.container.resolve('em') as EntityManager\n const member = await findOneWithDecryption(\n em,\n StaffTeamMember,\n { userId: auth.sub, deletedAt: null },\n undefined,\n { tenantId, organizationId },\n )\n return {\n canManageAll: false,\n canManageSelf,\n canManageUnavailability,\n memberId: member?.id ?? null,\n tenantId,\n organizationId,\n }\n}\n"],
5
+ "mappings": "AAIA,SAAS,6BAA6B;AACtC,SAAS,uBAAuB;AAkBzB,MAAM,8BAA8B;AACpC,MAAM,sBAAsB;AAC5B,MAAM,8BAA8B;AAE3C,eAAsB,+BACpB,KACkC;AAClC,QAAM,OAAO,IAAI;AACjB,QAAM,WAAW,MAAM,YAAY;AACnC,QAAM,iBAAiB,IAAI,0BAA0B,MAAM,SAAS;AACpE,MAAI,CAAC,QAAQ,CAAC,KAAK,OAAO,KAAK,UAAU;AACvC,WAAO;AAAA,MACL,cAAc;AAAA,MACd,eAAe;AAAA,MACf,yBAAyB;AAAA,MACzB,UAAU;AAAA,MACV;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,QAAM,OAAO,IAAI,UAAU,QAAQ,aAAa;AAChD,QAAM,eAAe,MAAM,KAAK;AAAA,IAC9B,KAAK;AAAA,IACL,CAAC,2BAA2B;AAAA,IAC5B,EAAE,UAAU,eAAe;AAAA,EAC7B;AACA,MAAI,cAAc;AAChB,WAAO;AAAA,MACL,cAAc;AAAA,MACd,eAAe;AAAA,MACf,yBAAyB;AAAA,MACzB,UAAU;AAAA,MACV;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,QAAM,CAAC,eAAe,uBAAuB,IAAI,MAAM,QAAQ,IAAI;AAAA,IACjE,KAAK,mBAAmB,KAAK,KAAK,CAAC,mBAAmB,GAAG,EAAE,UAAU,eAAe,CAAC;AAAA,IACrF,KAAK,mBAAmB,KAAK,KAAK,CAAC,2BAA2B,GAAG,EAAE,UAAU,eAAe,CAAC;AAAA,EAC/F,CAAC;AACD,MAAI,CAAC,eAAe;AAClB,WAAO;AAAA,MACL,cAAc;AAAA,MACd,eAAe;AAAA,MACf,yBAAyB;AAAA,MACzB,UAAU;AAAA,MACV;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,QAAM,KAAK,IAAI,UAAU,QAAQ,IAAI;AACrC,QAAM,SAAS,MAAM;AAAA,IACnB;AAAA,IACA;AAAA,IACA,EAAE,QAAQ,KAAK,KAAK,WAAW,KAAK;AAAA,IACpC;AAAA,IACA,EAAE,UAAU,eAAe;AAAA,EAC7B;AACA,SAAO;AAAA,IACL,cAAc;AAAA,IACd;AAAA,IACA;AAAA,IACA,UAAU,QAAQ,MAAM;AAAA,IACxB;AAAA,IACA;AAAA,EACF;AACF;",
6
+ "names": []
7
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.3-develop.3810.1.ad92c339f5",
3
+ "version": "0.6.3-develop.3820.1.636677865b",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -243,16 +243,16 @@
243
243
  "zod": "^4.4.3"
244
244
  },
245
245
  "peerDependencies": {
246
- "@open-mercato/ai-assistant": "0.6.3-develop.3810.1.ad92c339f5",
247
- "@open-mercato/shared": "0.6.3-develop.3810.1.ad92c339f5",
248
- "@open-mercato/ui": "0.6.3-develop.3810.1.ad92c339f5",
246
+ "@open-mercato/ai-assistant": "0.6.3-develop.3820.1.636677865b",
247
+ "@open-mercato/shared": "0.6.3-develop.3820.1.636677865b",
248
+ "@open-mercato/ui": "0.6.3-develop.3820.1.636677865b",
249
249
  "react": "^19.0.0",
250
250
  "react-dom": "^19.0.0"
251
251
  },
252
252
  "devDependencies": {
253
- "@open-mercato/ai-assistant": "0.6.3-develop.3810.1.ad92c339f5",
254
- "@open-mercato/shared": "0.6.3-develop.3810.1.ad92c339f5",
255
- "@open-mercato/ui": "0.6.3-develop.3810.1.ad92c339f5",
253
+ "@open-mercato/ai-assistant": "0.6.3-develop.3820.1.636677865b",
254
+ "@open-mercato/shared": "0.6.3-develop.3820.1.636677865b",
255
+ "@open-mercato/ui": "0.6.3-develop.3820.1.636677865b",
256
256
  "@testing-library/dom": "^10.4.1",
257
257
  "@testing-library/jest-dom": "^6.9.1",
258
258
  "@testing-library/react": "^16.3.1",
@@ -2,6 +2,33 @@
2
2
 
3
3
  The auth module handles authentication, authorization, users, roles, and RBAC.
4
4
 
5
+ ## Always
6
+
7
+ 1. Hash passwords with `bcryptjs` using cost >= 10.
8
+ 2. Use `findWithDecryption` / `findOneWithDecryption` for user queries.
9
+ 3. Prefer `requireFeatures` in page/API metadata for access control.
10
+ 4. Declare every module feature in `acl.ts` and seed role grants through `setup.ts`.
11
+ 5. Use wildcard-aware helpers such as `matchFeature`, `hasFeature`, and `hasAllFeatures` when inspecting raw granted features.
12
+
13
+ ## Ask First
14
+
15
+ - Ask before changing session token format, RBAC semantics, wildcard matching, super-admin behavior, or tenant provisioning outputs.
16
+ - Ask before changing login/reset/invitation error messages because auth messages can leak account existence.
17
+
18
+ ## Never
19
+
20
+ - Never log credentials, password reset tokens, session tokens, or decrypted user secrets.
21
+ - Never reveal whether an email exists through auth error messages.
22
+ - Never check raw ACL arrays with `includes(...)`, `Set.has(...)`, or ad hoc wildcard logic.
23
+
24
+ ## Validation Commands
25
+
26
+ ```bash
27
+ yarn generate
28
+ yarn workspace @open-mercato/core build
29
+ yarn workspace @open-mercato/core test
30
+ ```
31
+
5
32
  ## Data Model
6
33
 
7
34
  - **Users** — system users with credentials, profile, preferences
@@ -2,14 +2,33 @@
2
2
 
3
3
  Use the catalog module for products, categories, pricing, variants, and offers.
4
4
 
5
- ## MUST Rules
5
+ ## Always
6
6
 
7
- 1. **MUST NOT reimplement pricing logic** — use `selectBestPrice` and the resolver pipeline from `lib/pricing.ts`
7
+ 1. **MUST use `selectBestPrice` and the resolver pipeline from `lib/pricing.ts`** for pricing logic.
8
8
  2. **MUST use the DI token `catalogPricingService`** when resolving prices — ensures overrides take effect
9
9
  3. **MUST register custom pricing resolvers** with explicit priority (`registerCatalogPricingResolver(resolver, { priority })`)
10
10
  4. **MUST declare widget injections** in `widgets/injection/` and map via `injection-table.ts`
11
11
  5. **MUST follow the standard event pattern** in `events.ts` for all CRUD and lifecycle events
12
12
 
13
+ ## Ask First
14
+
15
+ - Ask before changing price-layer precedence, resolver priority semantics, event IDs, or released widget spot IDs.
16
+ - Ask before deleting or changing option schemas that existing variants may reference.
17
+
18
+ ## Never
19
+
20
+ - Never reimplement catalog pricing inline.
21
+ - Never delete option schemas while variants reference them.
22
+ - Never bypass `prepareMutation` for catalog AI tools that mutate products, prices, or media.
23
+
24
+ ## Validation Commands
25
+
26
+ ```bash
27
+ yarn db:generate
28
+ yarn generate
29
+ yarn workspace @open-mercato/core build
30
+ ```
31
+
13
32
  ## When You Need Pricing Logic
14
33
 
15
34
  1. Resolve `catalogPricingService` from DI
@@ -2,13 +2,32 @@
2
2
 
3
3
  Use the currencies module for multi-currency support, exchange rates, and currency conversion.
4
4
 
5
- ## MUST Rules
5
+ ## Always
6
6
 
7
7
  1. **MUST store currency amounts with 4 decimal precision** — never truncate to 2 decimals internally
8
8
  2. **MUST use date-based exchange rates** — always resolve rates for the transaction date, not "current" rate
9
9
  3. **MUST record both transaction currency and base currency amounts** — dual recording is mandatory for reporting
10
10
  4. **MUST calculate realized gains/losses** on payment: `(payment rate - invoice rate) × foreign amount`
11
- 5. **MUST NOT hard-delete exchange rate records** — rates are historical reference data
11
+ 5. **MUST keep financial postings atomic** — full transaction rollback on error
12
+
13
+ ## Ask First
14
+
15
+ - Ask before changing precision, exchange-rate lookup semantics, realized gain/loss formulas, or financial reporting fields.
16
+ - Ask before changing historical exchange-rate retention behavior.
17
+
18
+ ## Never
19
+
20
+ - Never truncate internal currency amounts to 2 decimals.
21
+ - Never hard-delete exchange rate records — rates are historical reference data.
22
+ - Never delete posted transactions or audit-trail entries.
23
+
24
+ ## Validation Commands
25
+
26
+ ```bash
27
+ yarn db:generate
28
+ yarn generate
29
+ yarn workspace @open-mercato/core build
30
+ ```
12
31
 
13
32
  ## Key Files
14
33
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Customer-facing identity and portal authentication with a two-tier RBAC model. This module manages customer user accounts, sessions, roles, invitations, and the authentication flow for the customer portal. It is separate from the internal `auth` module, which handles staff authentication.
4
4
 
5
- ## MUST Rules
5
+ ## Always
6
6
 
7
7
  1. **MUST hash passwords with `bcryptjs` (cost >= 10)** — never store plaintext passwords
8
8
  2. **MUST return minimal error messages on auth endpoints** — never reveal whether an email exists (use generic "Invalid email or password")
@@ -10,11 +10,31 @@ Customer-facing identity and portal authentication with a two-tier RBAC model. T
10
10
  4. **MUST validate all inputs with zod** — schemas live in `data/validators.ts`
11
11
  5. **MUST export `openApi`** from every API route file
12
12
  6. **MUST scope all queries by `tenantId`** and filter `deletedAt: null` for soft-deleted records
13
- 7. **MUST NOT expose cross-tenant data** — session validation checks tenant match
14
- 8. **MUST use `hashForLookup` for email-based lookups** — emails are stored with a deterministic hash for indexed queries
15
- 9. **MUST use `hashToken` for storing session/verification/reset tokens** raw tokens are never persisted
16
- 10. **MUST emit events via `emitCustomerAccountsEvent`** for all state changes (login, signup, lock, password reset)
17
- 11. **MUST NOT import staff auth services** — customer auth is a fully separate identity system
13
+ 7. **MUST use `hashForLookup` for email-based lookups** — emails are stored with a deterministic hash for indexed queries
14
+ 8. **MUST use `hashToken` for storing session/verification/reset tokens** — raw tokens are never persisted
15
+ 9. **MUST emit events via `emitCustomerAccountsEvent`** for all state changes (login, signup, lock, password reset)
16
+
17
+ ## Ask First
18
+
19
+ - Ask before changing cookie names, token TTLs, JWT claim shape, rate limits, lockout thresholds, or portal RBAC semantics.
20
+ - Ask before moving customer portal navigation into a different staff IA group.
21
+ - Ask before changing CRM auto-linking behavior.
22
+
23
+ ## Never
24
+
25
+ - Never store plaintext passwords or raw tokens.
26
+ - Never reveal whether an email exists.
27
+ - Never expose cross-tenant data — session validation checks tenant match.
28
+ - Never import staff auth services; customer auth is a fully separate identity system.
29
+ - Never use exact `includes(...)` checks for portal wildcard ACL matching.
30
+
31
+ ## Validation Commands
32
+
33
+ ```bash
34
+ yarn db:generate
35
+ yarn generate
36
+ yarn workspace @open-mercato/core build
37
+ ```
18
38
 
19
39
  ## Data Model
20
40
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  **This is the reference CRUD module.** When building new modules, copy patterns from here first.
4
4
 
5
- ## MUST Rules
5
+ ## Always
6
6
 
7
7
  1. **MUST use this module as the template** for new CRUD modules — copy file structure and patterns
8
8
  2. **MUST include all standard module files** — use the list below as a checklist
@@ -11,6 +11,25 @@
11
11
  5. **MUST capture custom field snapshots** in command `before`/`after` payloads for undo support
12
12
  6. **MUST use `useGuardedMutation` for non-`CrudForm` backend writes** (`POST`/`PUT`/`PATCH`/`DELETE`) and pass `retryLastMutation` in injection context
13
13
 
14
+ ## Ask First
15
+
16
+ - Ask before changing this module's reference patterns, standard module-file checklist, or AI mutation policies.
17
+ - Ask before changing customer data model relationships that downstream modules may copy.
18
+
19
+ ## Never
20
+
21
+ - Never bypass custom field normalization in CRUD create/update/read responses.
22
+ - Never omit undo snapshots for custom field mutations.
23
+ - Never write backend `POST`/`PUT`/`PATCH`/`DELETE` actions outside `CrudForm` without `useGuardedMutation`.
24
+
25
+ ## Validation Commands
26
+
27
+ ```bash
28
+ yarn db:generate
29
+ yarn generate
30
+ yarn workspace @open-mercato/core build
31
+ ```
32
+
14
33
  ## Key Reference Files — Copy From Here
15
34
 
16
35
  | When you need | Copy from |