@payez/next-mvp 4.8.2 → 4.8.3
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/dist/api-handlers/admin/audit.js +13 -21
- package/dist/api-handlers/admin/sessions.js +59 -45
- package/dist/api-handlers/admin/stats.js +1 -1
- package/dist/api-handlers/admin/users.js +15 -23
- package/dist/api-handlers/admin/vibe-data.d.ts +5 -0
- package/dist/api-handlers/admin/vibe-data.js +5 -0
- package/dist/vibe/client.d.ts +1 -1
- package/dist/vibe/client.js +2 -1
- package/dist/vibe/filters.d.ts +36 -0
- package/dist/vibe/filters.js +47 -0
- package/dist/vibe/generic.js +2 -1
- package/package.json +1 -1
- package/src/api-handlers/admin/audit.ts +12 -21
- package/src/api-handlers/admin/sessions.ts +65 -51
- package/src/api-handlers/admin/stats.ts +1 -1
- package/src/api-handlers/admin/users.ts +13 -24
- package/src/api-handlers/admin/vibe-data.ts +5 -0
- package/src/vibe/client.ts +4 -3
- package/src/vibe/filters.ts +52 -0
- package/src/vibe/generic.ts +2 -1
|
@@ -48,6 +48,7 @@ const startup_init_1 = require("../../lib/startup-init");
|
|
|
48
48
|
const client_key_1 = require("../../lib/client-key");
|
|
49
49
|
const roles_1 = require("../../lib/roles");
|
|
50
50
|
const crypto_1 = require("crypto");
|
|
51
|
+
const filters_1 = require("../../vibe/filters");
|
|
51
52
|
async function checkAdminRole(request) {
|
|
52
53
|
const session = await (0, auth_1.getSession)(request);
|
|
53
54
|
if (!session?.user) {
|
|
@@ -141,28 +142,19 @@ function createAuditHandler(config) {
|
|
|
141
142
|
const queryBody = {
|
|
142
143
|
page,
|
|
143
144
|
pageSize,
|
|
144
|
-
orderBy:
|
|
145
|
-
|
|
145
|
+
// PAY-1810: orderBy is a { field: dir } map; a bare string is rejected or ignored by Core.
|
|
146
|
+
orderBy: (0, filters_1.orderByMap)('created_at', 'desc'),
|
|
146
147
|
};
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (endDate) {
|
|
158
|
-
conditions.push({ field: 'created_at', operator: 'lte', value: endDate });
|
|
159
|
-
}
|
|
160
|
-
if (conditions.length === 1) {
|
|
161
|
-
queryBody.filter = conditions[0];
|
|
162
|
-
}
|
|
163
|
-
else if (conditions.length > 1) {
|
|
164
|
-
queryBody.filter = { operator: 'and', conditions };
|
|
165
|
-
}
|
|
148
|
+
// PAY-1810: Core filter forms only (vibe/filters.ts). The old `{ operator, conditions }` had no `filters` key, so two or more filters
|
|
149
|
+
// returned zero rows.
|
|
150
|
+
const filter = (0, filters_1.allOf)([
|
|
151
|
+
action ? (0, filters_1.eq)('action', action) : null,
|
|
152
|
+
userId ? (0, filters_1.eq)('user_id', userId) : null,
|
|
153
|
+
startDate ? (0, filters_1.gte)('created_at', startDate) : null,
|
|
154
|
+
endDate ? (0, filters_1.lte)('created_at', endDate) : null,
|
|
155
|
+
]);
|
|
156
|
+
if (filter)
|
|
157
|
+
queryBody.filter = filter;
|
|
166
158
|
const result = await vibeServiceRequest('/v1/collections/vibe_app/tables/audit_logs/query', { method: 'POST', body: queryBody });
|
|
167
159
|
if (!result.ok) {
|
|
168
160
|
return server_1.NextResponse.json({ error: result.error, code: 'UPSTREAM_ERROR', correlationId: result.correlationId }, { status: result.status || 500 });
|
|
@@ -49,6 +49,7 @@ const startup_init_1 = require("../../lib/startup-init");
|
|
|
49
49
|
const client_key_1 = require("../../lib/client-key");
|
|
50
50
|
const roles_1 = require("../../lib/roles");
|
|
51
51
|
const crypto_1 = require("crypto");
|
|
52
|
+
const filters_1 = require("../../vibe/filters");
|
|
52
53
|
/**
|
|
53
54
|
* Check if the current user has admin role
|
|
54
55
|
*/
|
|
@@ -156,23 +157,17 @@ function createSessionsHandler(config) {
|
|
|
156
157
|
const queryBody = {
|
|
157
158
|
page: 1,
|
|
158
159
|
pageSize: 100,
|
|
159
|
-
orderBy:
|
|
160
|
-
|
|
160
|
+
// PAY-1810: orderBy is a { field: dir } map; a bare string is rejected or ignored by Core.
|
|
161
|
+
orderBy: (0, filters_1.orderByMap)('created_at', 'desc'),
|
|
161
162
|
};
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
if (conditions.length === 1) {
|
|
171
|
-
queryBody.filter = conditions[0];
|
|
172
|
-
}
|
|
173
|
-
else if (conditions.length > 1) {
|
|
174
|
-
queryBody.filter = { operator: 'and', conditions };
|
|
175
|
-
}
|
|
163
|
+
// PAY-1810: Core filter forms only (vibe/filters.ts): compound uses `filters`, and substring is `contains` (`like` is not a Core
|
|
164
|
+
// operator and a flat unknown operator is DROPPED, which returned every session).
|
|
165
|
+
const filter = (0, filters_1.allOf)([
|
|
166
|
+
status && status !== 'all' ? (0, filters_1.eq)('status', status) : null,
|
|
167
|
+
email ? (0, filters_1.contains)('email', email) : null,
|
|
168
|
+
]);
|
|
169
|
+
if (filter)
|
|
170
|
+
queryBody.filter = filter;
|
|
176
171
|
const result = await vibeServiceRequest('/v1/collections/vibe_app/tables/login_sessions/query', { method: 'POST', body: queryBody });
|
|
177
172
|
if (!result.ok) {
|
|
178
173
|
return server_1.NextResponse.json({ error: result.error, code: 'UPSTREAM_ERROR', correlationId: result.correlationId }, { status: result.status || 500 });
|
|
@@ -239,7 +234,9 @@ function createSessionsHandler(config) {
|
|
|
239
234
|
if (action === 'revoke' && sessionId) {
|
|
240
235
|
// Revoke single session
|
|
241
236
|
const result = await vibeServiceRequest(`/v1/collections/vibe_app/tables/login_sessions/${sessionId}`, {
|
|
242
|
-
|
|
237
|
+
// PAY-1810 (NightHawk 75394): PATCH, not PUT. A PUT to a Vibe document REPLACES it (Core UpdateAsync SET data = body), so this 3-field body
|
|
238
|
+
// erased idp_user_id / session_token_hash / the rest of the row: the revoked row then vanished from the revocation check and the user list.
|
|
239
|
+
method: 'PATCH',
|
|
243
240
|
body: {
|
|
244
241
|
status: 'revoked',
|
|
245
242
|
revoked_at: new Date().toISOString(),
|
|
@@ -252,39 +249,56 @@ function createSessionsHandler(config) {
|
|
|
252
249
|
return server_1.NextResponse.json({ success: true });
|
|
253
250
|
}
|
|
254
251
|
if (action === 'revoke_all' && userId) {
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
operator: 'and',
|
|
261
|
-
conditions: [
|
|
262
|
-
{ field: 'idp_user_id', operator: 'eq', value: userId },
|
|
263
|
-
{ field: 'status', operator: 'eq', value: 'active' },
|
|
264
|
-
],
|
|
265
|
-
},
|
|
266
|
-
},
|
|
267
|
-
});
|
|
268
|
-
if (!queryResult.ok) {
|
|
269
|
-
return server_1.NextResponse.json({ error: queryResult.error, code: 'UPSTREAM_ERROR', correlationId: queryResult.correlationId }, { status: queryResult.status || 500 });
|
|
270
|
-
}
|
|
271
|
-
const sessions = queryResult.data?.data || queryResult.data?.documents || [];
|
|
252
|
+
// PAY-1810: one /query call returns ONE page (Core's default page size is 20), so a user with more active sessions than a page would keep
|
|
253
|
+
// the rest signed in. Revoked sessions leave the `status = active` filter, so re-reading page 1 walks the whole set. It stops when a read
|
|
254
|
+
// is empty (complete), when a round revokes nothing (no progress: never loops on a failing write), or after REVOKE_ALL_MAX_ROUNDS.
|
|
255
|
+
const REVOKE_ALL_PAGE_SIZE = 50;
|
|
256
|
+
const REVOKE_ALL_MAX_ROUNDS = 20;
|
|
272
257
|
let revokedCount = 0;
|
|
273
|
-
|
|
274
|
-
for (
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
method: 'PUT',
|
|
258
|
+
let complete = false;
|
|
259
|
+
for (let round = 0; round < REVOKE_ALL_MAX_ROUNDS; round++) {
|
|
260
|
+
const queryResult = await vibeServiceRequest('/v1/collections/vibe_app/tables/login_sessions/query', {
|
|
261
|
+
method: 'POST',
|
|
278
262
|
body: {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
263
|
+
// PAY-1810: `conditions` is not a Core key (it read zero rows, so revoke_all revoked 0 and answered success); `filters` is.
|
|
264
|
+
filter: (0, filters_1.allOf)([(0, filters_1.eq)('idp_user_id', userId), (0, filters_1.eq)('status', 'active')]),
|
|
265
|
+
page: 1,
|
|
266
|
+
pageSize: REVOKE_ALL_PAGE_SIZE,
|
|
282
267
|
},
|
|
283
268
|
});
|
|
284
|
-
if (
|
|
285
|
-
|
|
269
|
+
if (!queryResult.ok) {
|
|
270
|
+
// The first read failing is a plain upstream error (as before). A later one leaves a partial result, reported as incomplete.
|
|
271
|
+
if (round === 0) {
|
|
272
|
+
return server_1.NextResponse.json({ error: queryResult.error, code: 'UPSTREAM_ERROR', correlationId: queryResult.correlationId }, { status: queryResult.status || 500 });
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
const sessions = queryResult.data?.data || queryResult.data?.documents || [];
|
|
277
|
+
if (sessions.length === 0) {
|
|
278
|
+
complete = true;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
let revokedThisRound = 0;
|
|
282
|
+
for (const session of sessions) {
|
|
283
|
+
const id = session.id || session.document_id;
|
|
284
|
+
const result = await vibeServiceRequest(`/v1/collections/vibe_app/tables/login_sessions/${id}`, {
|
|
285
|
+
method: 'PATCH', // PAY-1810: PATCH merges {status, revoked_at, revoked_by}; PUT would replace the whole row (see the single revoke above)
|
|
286
|
+
body: {
|
|
287
|
+
status: 'revoked',
|
|
288
|
+
revoked_at: new Date().toISOString(),
|
|
289
|
+
revoked_by: `admin:${adminCheck.userId}`,
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
if (result.ok) {
|
|
293
|
+
revokedCount++;
|
|
294
|
+
revokedThisRound++;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (revokedThisRound === 0)
|
|
298
|
+
break;
|
|
286
299
|
}
|
|
287
|
-
|
|
300
|
+
// success only when the whole set was revoked: a sign-out control must not say success when every write failed (Scout 75399).
|
|
301
|
+
return server_1.NextResponse.json({ success: complete, revokedCount, complete });
|
|
288
302
|
}
|
|
289
303
|
return server_1.NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
290
304
|
},
|
|
@@ -142,7 +142,7 @@ function createStatsHandler(config) {
|
|
|
142
142
|
// 1. Users count via HMAC proxy (Vibe collection query)
|
|
143
143
|
vibeServiceRequest('/v1/collections/vibe_app/tables/users/query', {
|
|
144
144
|
method: 'POST',
|
|
145
|
-
body: { page: 1, pageSize: 500, orderBy:
|
|
145
|
+
body: { page: 1, pageSize: 500, orderBy: { created_at: 'desc' } }, // PAY-1810: orderBy is a { field: dir } map
|
|
146
146
|
}),
|
|
147
147
|
// 2. Tier distribution from analytics endpoint (uses purchases table)
|
|
148
148
|
vibeServiceRequest('/v1/analytics/tier-distribution?includeTrend=false', { method: 'GET' }),
|
|
@@ -48,6 +48,7 @@ const startup_init_1 = require("../../lib/startup-init");
|
|
|
48
48
|
const client_key_1 = require("../../lib/client-key");
|
|
49
49
|
const roles_1 = require("../../lib/roles");
|
|
50
50
|
const crypto_1 = require("crypto");
|
|
51
|
+
const filters_1 = require("../../vibe/filters");
|
|
51
52
|
async function checkAdminRole(request) {
|
|
52
53
|
const session = await (0, auth_1.getSession)(request);
|
|
53
54
|
if (!session?.user) {
|
|
@@ -139,28 +140,17 @@ function createUsersHandler(config) {
|
|
|
139
140
|
const queryBody = {
|
|
140
141
|
page,
|
|
141
142
|
pageSize,
|
|
142
|
-
orderBy:
|
|
143
|
-
|
|
143
|
+
// PAY-1810: orderBy is a { field: dir } map; a bare string is rejected or ignored by Core.
|
|
144
|
+
orderBy: (0, filters_1.orderByMap)('created_at', 'desc'),
|
|
144
145
|
};
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
});
|
|
154
|
-
}
|
|
155
|
-
if (tier) {
|
|
156
|
-
conditions.push({ field: 'tier', operator: 'eq', value: tier });
|
|
157
|
-
}
|
|
158
|
-
if (conditions.length === 1) {
|
|
159
|
-
queryBody.filter = conditions[0];
|
|
160
|
-
}
|
|
161
|
-
else if (conditions.length > 1) {
|
|
162
|
-
queryBody.filter = { operator: 'and', conditions };
|
|
163
|
-
}
|
|
146
|
+
// PAY-1810: Core filter forms only (vibe/filters.ts). The old `{ operator, conditions }` had no `filters` key, so a search returned zero rows,
|
|
147
|
+
// and `like` is not a Core operator; substring is `contains` (case-insensitive).
|
|
148
|
+
const filter = (0, filters_1.allOf)([
|
|
149
|
+
search ? (0, filters_1.anyOf)([(0, filters_1.contains)('email', search), (0, filters_1.contains)('display_name', search)]) : null,
|
|
150
|
+
tier ? (0, filters_1.eq)('tier', tier) : null,
|
|
151
|
+
]);
|
|
152
|
+
if (filter)
|
|
153
|
+
queryBody.filter = filter;
|
|
164
154
|
const result = await vibeServiceRequest('/v1/collections/vibe_app/tables/users/query', { method: 'POST', body: queryBody });
|
|
165
155
|
if (!result.ok) {
|
|
166
156
|
return server_1.NextResponse.json({ error: result.error, code: 'UPSTREAM_ERROR', correlationId: result.correlationId }, { status: result.status || 500 });
|
|
@@ -211,14 +201,16 @@ function createUsersHandler(config) {
|
|
|
211
201
|
return server_1.NextResponse.json({ stats });
|
|
212
202
|
}
|
|
213
203
|
if (action === 'update_tier' && userId && tier) {
|
|
214
|
-
const result = await vibeServiceRequest(`/v1/collections/vibe_app/tables/users/${userId}`, { method: '
|
|
204
|
+
const result = await vibeServiceRequest(`/v1/collections/vibe_app/tables/users/${userId}`, { method: 'PATCH', body: { tier } } // PAY-2041: merge one field; PUT replaced the whole user row
|
|
205
|
+
);
|
|
215
206
|
if (!result.ok) {
|
|
216
207
|
return server_1.NextResponse.json({ error: result.error, code: 'UPSTREAM_ERROR', correlationId: result.correlationId }, { status: result.status || 500 });
|
|
217
208
|
}
|
|
218
209
|
return server_1.NextResponse.json({ success: true });
|
|
219
210
|
}
|
|
220
211
|
if (action === 'update_credits' && userId && credits !== undefined) {
|
|
221
|
-
const result = await vibeServiceRequest(`/v1/collections/vibe_app/tables/users/${userId}`, { method: '
|
|
212
|
+
const result = await vibeServiceRequest(`/v1/collections/vibe_app/tables/users/${userId}`, { method: 'PATCH', body: { credits } } // PAY-2041: merge one field; PUT replaced the whole user row
|
|
213
|
+
);
|
|
222
214
|
if (!result.ok) {
|
|
223
215
|
return server_1.NextResponse.json({ error: result.error, code: 'UPSTREAM_ERROR', correlationId: result.correlationId }, { status: result.status || 500 });
|
|
224
216
|
}
|
|
@@ -48,6 +48,11 @@ export declare function createGetRecordHandler(config: AdminVibeHandlerConfig):
|
|
|
48
48
|
/**
|
|
49
49
|
* PUT /api/admin/vibe/data/[collection]/[table]/[id]
|
|
50
50
|
* Update record (admin access)
|
|
51
|
+
*
|
|
52
|
+
* PAY-2041: this is deliberately a REPLACE. An editor save sends the whole record, and Core PUT sets the
|
|
53
|
+
* document data to the body, so a field the admin removed stays removed. Do not switch it to PATCH: a merge
|
|
54
|
+
* could never delete a key. Callers that hold only some fields (VibeTable.update, generic update, the
|
|
55
|
+
* users tier/credits writes) use PATCH instead. Ruled by BAPert 75440 (NightHawk 75437, Scout 75439 agree).
|
|
51
56
|
*/
|
|
52
57
|
export declare function createUpdateRecordHandler(config: AdminVibeHandlerConfig): (request: NextRequest, { params }: {
|
|
53
58
|
params: Promise<{
|
|
@@ -218,6 +218,11 @@ function createGetRecordHandler(config) {
|
|
|
218
218
|
/**
|
|
219
219
|
* PUT /api/admin/vibe/data/[collection]/[table]/[id]
|
|
220
220
|
* Update record (admin access)
|
|
221
|
+
*
|
|
222
|
+
* PAY-2041: this is deliberately a REPLACE. An editor save sends the whole record, and Core PUT sets the
|
|
223
|
+
* document data to the body, so a field the admin removed stays removed. Do not switch it to PATCH: a merge
|
|
224
|
+
* could never delete a key. Callers that hold only some fields (VibeTable.update, generic update, the
|
|
225
|
+
* users tier/credits writes) use PATCH instead. Ruled by BAPert 75440 (NightHawk 75437, Scout 75439 agree).
|
|
221
226
|
*/
|
|
222
227
|
function createUpdateRecordHandler(config) {
|
|
223
228
|
return async function PUT(request, { params }) {
|
package/dist/vibe/client.d.ts
CHANGED
|
@@ -345,7 +345,7 @@ export declare class VibeClient {
|
|
|
345
345
|
* - 401/403 errors: No retry (auth errors)
|
|
346
346
|
* - Network errors: Retry with backoff
|
|
347
347
|
*/
|
|
348
|
-
request<T>(urlOrPath: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE', body?: unknown, authToken?: string): Promise<VibeResponse<T>>;
|
|
348
|
+
request<T>(urlOrPath: string, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', body?: unknown, authToken?: string): Promise<VibeResponse<T>>;
|
|
349
349
|
/**
|
|
350
350
|
* Parse error response body, handling non-JSON responses gracefully.
|
|
351
351
|
*/
|
package/dist/vibe/client.js
CHANGED
|
@@ -331,7 +331,8 @@ class VibeTableDelegate {
|
|
|
331
331
|
*/
|
|
332
332
|
async update(options) {
|
|
333
333
|
const endpoint = this.tableEndpoint(options.where.id);
|
|
334
|
-
|
|
334
|
+
// PAY-2041: PATCH, not PUT. Core PUT REPLACES the document (UpdateAsync SET data = body), and `data` here is Partial, so PUT erased every field not named. PATCH merges (DocumentsController.Patch).
|
|
335
|
+
const response = await this.client.request(endpoint, 'PATCH', options.data);
|
|
335
336
|
return this.requireRow(response.data, 'update');
|
|
336
337
|
}
|
|
337
338
|
/**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1810: the ONE place the package builds Vibe /query filters and orderBy, in the shapes Core's VibeQueryBuilder accepts.
|
|
3
|
+
*
|
|
4
|
+
* Core accepts exactly three filter forms (PayEz.Vibe.Public.Api VibeQueryModels / VibeQueryBuilder):
|
|
5
|
+
* standard { field: { op: value } } with AND / OR arrays
|
|
6
|
+
* flat { field, operator, value }
|
|
7
|
+
* compound { operator: 'and' | 'or', filters: [ ...nodes ] } <- the key is `filters`, NOT `conditions`
|
|
8
|
+
* What goes wrong with the shapes this module replaces:
|
|
9
|
+
* - { operator, conditions } has no `filters` key, so Core reads `operator` / `conditions` as COLUMN names and returns ZERO rows
|
|
10
|
+
* (a broken search; for revoke-all, "revoked 0 sessions" reported as success).
|
|
11
|
+
* - operator 'like' is not a Core operator; in a flat filter it is DROPPED and the query returns ALL rows. Core's substring operator is
|
|
12
|
+
* `contains` (ILIKE, case-insensitive); `ilike` is case-insensitive EQUALS.
|
|
13
|
+
* - orderBy must be a { field: 'asc' | 'desc' } map (Core's model is Dictionary<string,string>); a bare string is rejected or ignored, never honoured.
|
|
14
|
+
*/
|
|
15
|
+
export type VibeOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'startsWith' | 'endsWith' | 'ilike' | 'in' | 'notIn';
|
|
16
|
+
export interface VibeFlatFilter {
|
|
17
|
+
field: string;
|
|
18
|
+
operator: VibeOperator;
|
|
19
|
+
value: unknown;
|
|
20
|
+
}
|
|
21
|
+
export interface VibeCompoundFilter {
|
|
22
|
+
operator: 'and' | 'or';
|
|
23
|
+
filters: VibeFilter[];
|
|
24
|
+
}
|
|
25
|
+
export type VibeFilter = VibeFlatFilter | VibeCompoundFilter;
|
|
26
|
+
export declare const eq: (field: string, value: unknown) => VibeFlatFilter;
|
|
27
|
+
export declare const gte: (field: string, value: unknown) => VibeFlatFilter;
|
|
28
|
+
export declare const lte: (field: string, value: unknown) => VibeFlatFilter;
|
|
29
|
+
/** Case-insensitive substring (Core `contains` = ILIKE). The value is the bare text: no % wildcards. */
|
|
30
|
+
export declare const contains: (field: string, value: string) => VibeFlatFilter;
|
|
31
|
+
/** AND of the given nodes: none -> undefined (no filter), one -> that node, several -> a compound with `filters`. */
|
|
32
|
+
export declare function allOf(nodes: Array<VibeFilter | undefined | null>): VibeFilter | undefined;
|
|
33
|
+
/** OR of the given nodes: none -> undefined, one -> that node, several -> a compound with `filters`. */
|
|
34
|
+
export declare function anyOf(nodes: Array<VibeFilter | undefined | null>): VibeFilter | undefined;
|
|
35
|
+
/** orderBy as Core binds it. */
|
|
36
|
+
export declare function orderByMap(field: string, direction?: 'asc' | 'desc'): Record<string, 'asc' | 'desc'>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* PAY-1810: the ONE place the package builds Vibe /query filters and orderBy, in the shapes Core's VibeQueryBuilder accepts.
|
|
4
|
+
*
|
|
5
|
+
* Core accepts exactly three filter forms (PayEz.Vibe.Public.Api VibeQueryModels / VibeQueryBuilder):
|
|
6
|
+
* standard { field: { op: value } } with AND / OR arrays
|
|
7
|
+
* flat { field, operator, value }
|
|
8
|
+
* compound { operator: 'and' | 'or', filters: [ ...nodes ] } <- the key is `filters`, NOT `conditions`
|
|
9
|
+
* What goes wrong with the shapes this module replaces:
|
|
10
|
+
* - { operator, conditions } has no `filters` key, so Core reads `operator` / `conditions` as COLUMN names and returns ZERO rows
|
|
11
|
+
* (a broken search; for revoke-all, "revoked 0 sessions" reported as success).
|
|
12
|
+
* - operator 'like' is not a Core operator; in a flat filter it is DROPPED and the query returns ALL rows. Core's substring operator is
|
|
13
|
+
* `contains` (ILIKE, case-insensitive); `ilike` is case-insensitive EQUALS.
|
|
14
|
+
* - orderBy must be a { field: 'asc' | 'desc' } map (Core's model is Dictionary<string,string>); a bare string is rejected or ignored, never honoured.
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.contains = exports.lte = exports.gte = exports.eq = void 0;
|
|
18
|
+
exports.allOf = allOf;
|
|
19
|
+
exports.anyOf = anyOf;
|
|
20
|
+
exports.orderByMap = orderByMap;
|
|
21
|
+
const eq = (field, value) => ({ field, operator: 'eq', value });
|
|
22
|
+
exports.eq = eq;
|
|
23
|
+
const gte = (field, value) => ({ field, operator: 'gte', value });
|
|
24
|
+
exports.gte = gte;
|
|
25
|
+
const lte = (field, value) => ({ field, operator: 'lte', value });
|
|
26
|
+
exports.lte = lte;
|
|
27
|
+
/** Case-insensitive substring (Core `contains` = ILIKE). The value is the bare text: no % wildcards. */
|
|
28
|
+
const contains = (field, value) => ({ field, operator: 'contains', value });
|
|
29
|
+
exports.contains = contains;
|
|
30
|
+
/** AND of the given nodes: none -> undefined (no filter), one -> that node, several -> a compound with `filters`. */
|
|
31
|
+
function allOf(nodes) {
|
|
32
|
+
const f = nodes.filter((n) => !!n);
|
|
33
|
+
if (f.length === 0)
|
|
34
|
+
return undefined;
|
|
35
|
+
return f.length === 1 ? f[0] : { operator: 'and', filters: f };
|
|
36
|
+
}
|
|
37
|
+
/** OR of the given nodes: none -> undefined, one -> that node, several -> a compound with `filters`. */
|
|
38
|
+
function anyOf(nodes) {
|
|
39
|
+
const f = nodes.filter((n) => !!n);
|
|
40
|
+
if (f.length === 0)
|
|
41
|
+
return undefined;
|
|
42
|
+
return f.length === 1 ? f[0] : { operator: 'or', filters: f };
|
|
43
|
+
}
|
|
44
|
+
/** orderBy as Core binds it. */
|
|
45
|
+
function orderByMap(field, direction = 'desc') {
|
|
46
|
+
return { [field]: direction };
|
|
47
|
+
}
|
package/dist/vibe/generic.js
CHANGED
|
@@ -208,7 +208,8 @@ class GenericTableDelegate {
|
|
|
208
208
|
async update(options) {
|
|
209
209
|
const path = vibeTablePath(this.collection, this.tableName, options.where.id);
|
|
210
210
|
const url = `${this.client.getBaseUrl()}${path}`;
|
|
211
|
-
|
|
211
|
+
// PAY-2041: PATCH merges; PUT would replace the whole document with this Partial.
|
|
212
|
+
const response = await this.client.request(url, 'PATCH', options.data);
|
|
212
213
|
const unwrapped = (0, document_unwrap_2.unwrapVibeDocument)(response.data);
|
|
213
214
|
if (!unwrapped) {
|
|
214
215
|
throw new errors_1.VibeServiceError('Failed to parse updated document');
|
package/package.json
CHANGED
|
@@ -13,6 +13,7 @@ import { getStartupIDPConfig } from '../../lib/startup-init';
|
|
|
13
13
|
import { resolveClientKey } from '../../lib/client-key';
|
|
14
14
|
import { ADMIN_ROLES, hasAnyRole } from '../../lib/roles';
|
|
15
15
|
import { randomUUID } from 'crypto';
|
|
16
|
+
import { allOf, eq, gte, lte, orderByMap } from '../../vibe/filters';
|
|
16
17
|
|
|
17
18
|
interface VibeRequestOptions {
|
|
18
19
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
@@ -131,29 +132,19 @@ export function createAuditHandler(config: AdminAuditHandlerConfig) {
|
|
|
131
132
|
const queryBody: any = {
|
|
132
133
|
page,
|
|
133
134
|
pageSize,
|
|
134
|
-
orderBy:
|
|
135
|
-
|
|
135
|
+
// PAY-1810: orderBy is a { field: dir } map; a bare string is rejected or ignored by Core.
|
|
136
|
+
orderBy: orderByMap('created_at', 'desc'),
|
|
136
137
|
};
|
|
137
138
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
if (endDate) {
|
|
149
|
-
conditions.push({ field: 'created_at', operator: 'lte', value: endDate });
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (conditions.length === 1) {
|
|
153
|
-
queryBody.filter = conditions[0];
|
|
154
|
-
} else if (conditions.length > 1) {
|
|
155
|
-
queryBody.filter = { operator: 'and', conditions };
|
|
156
|
-
}
|
|
139
|
+
// PAY-1810: Core filter forms only (vibe/filters.ts). The old `{ operator, conditions }` had no `filters` key, so two or more filters
|
|
140
|
+
// returned zero rows.
|
|
141
|
+
const filter = allOf([
|
|
142
|
+
action ? eq('action', action) : null,
|
|
143
|
+
userId ? eq('user_id', userId) : null,
|
|
144
|
+
startDate ? gte('created_at', startDate) : null,
|
|
145
|
+
endDate ? lte('created_at', endDate) : null,
|
|
146
|
+
]);
|
|
147
|
+
if (filter) queryBody.filter = filter;
|
|
157
148
|
|
|
158
149
|
const result = await vibeServiceRequest<any>(
|
|
159
150
|
'/v1/collections/vibe_app/tables/audit_logs/query',
|
|
@@ -14,9 +14,10 @@ import { getStartupIDPConfig } from '../../lib/startup-init';
|
|
|
14
14
|
import { resolveClientKey } from '../../lib/client-key';
|
|
15
15
|
import { ADMIN_ROLES, hasAnyRole } from '../../lib/roles';
|
|
16
16
|
import { randomUUID } from 'crypto';
|
|
17
|
+
import { allOf, contains, eq, orderByMap } from '../../vibe/filters';
|
|
17
18
|
|
|
18
19
|
interface VibeRequestOptions {
|
|
19
|
-
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
20
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
20
21
|
body?: unknown;
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -154,24 +155,17 @@ export function createSessionsHandler(config: AdminSessionsHandlerConfig) {
|
|
|
154
155
|
const queryBody: any = {
|
|
155
156
|
page: 1,
|
|
156
157
|
pageSize: 100,
|
|
157
|
-
orderBy:
|
|
158
|
-
|
|
158
|
+
// PAY-1810: orderBy is a { field: dir } map; a bare string is rejected or ignored by Core.
|
|
159
|
+
orderBy: orderByMap('created_at', 'desc'),
|
|
159
160
|
};
|
|
160
161
|
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (conditions.length === 1) {
|
|
171
|
-
queryBody.filter = conditions[0];
|
|
172
|
-
} else if (conditions.length > 1) {
|
|
173
|
-
queryBody.filter = { operator: 'and', conditions };
|
|
174
|
-
}
|
|
162
|
+
// PAY-1810: Core filter forms only (vibe/filters.ts): compound uses `filters`, and substring is `contains` (`like` is not a Core
|
|
163
|
+
// operator and a flat unknown operator is DROPPED, which returned every session).
|
|
164
|
+
const filter = allOf([
|
|
165
|
+
status && status !== 'all' ? eq('status', status) : null,
|
|
166
|
+
email ? contains('email', email) : null,
|
|
167
|
+
]);
|
|
168
|
+
if (filter) queryBody.filter = filter;
|
|
175
169
|
|
|
176
170
|
const result = await vibeServiceRequest<any>(
|
|
177
171
|
'/v1/collections/vibe_app/tables/login_sessions/query',
|
|
@@ -260,7 +254,9 @@ export function createSessionsHandler(config: AdminSessionsHandlerConfig) {
|
|
|
260
254
|
const result = await vibeServiceRequest<any>(
|
|
261
255
|
`/v1/collections/vibe_app/tables/login_sessions/${sessionId}`,
|
|
262
256
|
{
|
|
263
|
-
|
|
257
|
+
// PAY-1810 (NightHawk 75394): PATCH, not PUT. A PUT to a Vibe document REPLACES it (Core UpdateAsync SET data = body), so this 3-field body
|
|
258
|
+
// erased idp_user_id / session_token_hash / the rest of the row: the revoked row then vanished from the revocation check and the user list.
|
|
259
|
+
method: 'PATCH',
|
|
264
260
|
body: {
|
|
265
261
|
status: 'revoked',
|
|
266
262
|
revoked_at: new Date().toISOString(),
|
|
@@ -277,48 +273,66 @@ export function createSessionsHandler(config: AdminSessionsHandlerConfig) {
|
|
|
277
273
|
}
|
|
278
274
|
|
|
279
275
|
if (action === 'revoke_all' && userId) {
|
|
280
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
body: {
|
|
286
|
-
filter: {
|
|
287
|
-
operator: 'and',
|
|
288
|
-
conditions: [
|
|
289
|
-
{ field: 'idp_user_id', operator: 'eq', value: userId },
|
|
290
|
-
{ field: 'status', operator: 'eq', value: 'active' },
|
|
291
|
-
],
|
|
292
|
-
},
|
|
293
|
-
},
|
|
294
|
-
}
|
|
295
|
-
);
|
|
296
|
-
|
|
297
|
-
if (!queryResult.ok) {
|
|
298
|
-
return NextResponse.json({ error: queryResult.error, code: 'UPSTREAM_ERROR', correlationId: queryResult.correlationId }, { status: queryResult.status || 500 });
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
const sessions = queryResult.data?.data || queryResult.data?.documents || [];
|
|
276
|
+
// PAY-1810: one /query call returns ONE page (Core's default page size is 20), so a user with more active sessions than a page would keep
|
|
277
|
+
// the rest signed in. Revoked sessions leave the `status = active` filter, so re-reading page 1 walks the whole set. It stops when a read
|
|
278
|
+
// is empty (complete), when a round revokes nothing (no progress: never loops on a failing write), or after REVOKE_ALL_MAX_ROUNDS.
|
|
279
|
+
const REVOKE_ALL_PAGE_SIZE = 50;
|
|
280
|
+
const REVOKE_ALL_MAX_ROUNDS = 20;
|
|
302
281
|
let revokedCount = 0;
|
|
282
|
+
let complete = false;
|
|
303
283
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const result = await vibeServiceRequest<any>(
|
|
308
|
-
`/v1/collections/vibe_app/tables/login_sessions/${id}`,
|
|
284
|
+
for (let round = 0; round < REVOKE_ALL_MAX_ROUNDS; round++) {
|
|
285
|
+
const queryResult = await vibeServiceRequest<any>(
|
|
286
|
+
'/v1/collections/vibe_app/tables/login_sessions/query',
|
|
309
287
|
{
|
|
310
|
-
method: '
|
|
288
|
+
method: 'POST',
|
|
311
289
|
body: {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
290
|
+
// PAY-1810: `conditions` is not a Core key (it read zero rows, so revoke_all revoked 0 and answered success); `filters` is.
|
|
291
|
+
filter: allOf([eq('idp_user_id', userId), eq('status', 'active')]),
|
|
292
|
+
page: 1,
|
|
293
|
+
pageSize: REVOKE_ALL_PAGE_SIZE,
|
|
315
294
|
},
|
|
316
295
|
}
|
|
317
296
|
);
|
|
318
|
-
|
|
297
|
+
|
|
298
|
+
if (!queryResult.ok) {
|
|
299
|
+
// The first read failing is a plain upstream error (as before). A later one leaves a partial result, reported as incomplete.
|
|
300
|
+
if (round === 0) {
|
|
301
|
+
return NextResponse.json({ error: queryResult.error, code: 'UPSTREAM_ERROR', correlationId: queryResult.correlationId }, { status: queryResult.status || 500 });
|
|
302
|
+
}
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const sessions = queryResult.data?.data || queryResult.data?.documents || [];
|
|
307
|
+
if (sessions.length === 0) {
|
|
308
|
+
complete = true;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
let revokedThisRound = 0;
|
|
313
|
+
for (const session of sessions) {
|
|
314
|
+
const id = session.id || session.document_id;
|
|
315
|
+
const result = await vibeServiceRequest<any>(
|
|
316
|
+
`/v1/collections/vibe_app/tables/login_sessions/${id}`,
|
|
317
|
+
{
|
|
318
|
+
method: 'PATCH', // PAY-1810: PATCH merges {status, revoked_at, revoked_by}; PUT would replace the whole row (see the single revoke above)
|
|
319
|
+
body: {
|
|
320
|
+
status: 'revoked',
|
|
321
|
+
revoked_at: new Date().toISOString(),
|
|
322
|
+
revoked_by: `admin:${adminCheck.userId}`,
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
);
|
|
326
|
+
if (result.ok) {
|
|
327
|
+
revokedCount++;
|
|
328
|
+
revokedThisRound++;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (revokedThisRound === 0) break;
|
|
319
332
|
}
|
|
320
333
|
|
|
321
|
-
|
|
334
|
+
// success only when the whole set was revoked: a sign-out control must not say success when every write failed (Scout 75399).
|
|
335
|
+
return NextResponse.json({ success: complete, revokedCount, complete });
|
|
322
336
|
}
|
|
323
337
|
|
|
324
338
|
return NextResponse.json({ error: 'Invalid action' }, { status: 400 });
|
|
@@ -133,7 +133,7 @@ export function createStatsHandler(config: AdminStatsHandlerConfig) {
|
|
|
133
133
|
// 1. Users count via HMAC proxy (Vibe collection query)
|
|
134
134
|
vibeServiceRequest<any>('/v1/collections/vibe_app/tables/users/query', {
|
|
135
135
|
method: 'POST',
|
|
136
|
-
body: { page: 1, pageSize: 500, orderBy:
|
|
136
|
+
body: { page: 1, pageSize: 500, orderBy: { created_at: 'desc' } }, // PAY-1810: orderBy is a { field: dir } map
|
|
137
137
|
}),
|
|
138
138
|
|
|
139
139
|
// 2. Tier distribution from analytics endpoint (uses purchases table)
|
|
@@ -13,9 +13,10 @@ import { getStartupIDPConfig } from '../../lib/startup-init';
|
|
|
13
13
|
import { resolveClientKey } from '../../lib/client-key';
|
|
14
14
|
import { ADMIN_ROLES, hasAnyRole } from '../../lib/roles';
|
|
15
15
|
import { randomUUID } from 'crypto';
|
|
16
|
+
import { allOf, anyOf, contains, eq, orderByMap } from '../../vibe/filters';
|
|
16
17
|
|
|
17
18
|
interface VibeRequestOptions {
|
|
18
|
-
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
19
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
19
20
|
body?: unknown;
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -129,29 +130,17 @@ export function createUsersHandler(config: AdminUsersHandlerConfig) {
|
|
|
129
130
|
const queryBody: any = {
|
|
130
131
|
page,
|
|
131
132
|
pageSize,
|
|
132
|
-
orderBy:
|
|
133
|
-
|
|
133
|
+
// PAY-1810: orderBy is a { field: dir } map; a bare string is rejected or ignored by Core.
|
|
134
|
+
orderBy: orderByMap('created_at', 'desc'),
|
|
134
135
|
};
|
|
135
136
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
],
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
if (tier) {
|
|
147
|
-
conditions.push({ field: 'tier', operator: 'eq', value: tier });
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (conditions.length === 1) {
|
|
151
|
-
queryBody.filter = conditions[0];
|
|
152
|
-
} else if (conditions.length > 1) {
|
|
153
|
-
queryBody.filter = { operator: 'and', conditions };
|
|
154
|
-
}
|
|
137
|
+
// PAY-1810: Core filter forms only (vibe/filters.ts). The old `{ operator, conditions }` had no `filters` key, so a search returned zero rows,
|
|
138
|
+
// and `like` is not a Core operator; substring is `contains` (case-insensitive).
|
|
139
|
+
const filter = allOf([
|
|
140
|
+
search ? anyOf([contains('email', search), contains('display_name', search)]) : null,
|
|
141
|
+
tier ? eq('tier', tier) : null,
|
|
142
|
+
]);
|
|
143
|
+
if (filter) queryBody.filter = filter;
|
|
155
144
|
|
|
156
145
|
const result = await vibeServiceRequest<any>(
|
|
157
146
|
'/v1/collections/vibe_app/tables/users/query',
|
|
@@ -222,7 +211,7 @@ export function createUsersHandler(config: AdminUsersHandlerConfig) {
|
|
|
222
211
|
if (action === 'update_tier' && userId && tier) {
|
|
223
212
|
const result = await vibeServiceRequest<any>(
|
|
224
213
|
`/v1/collections/vibe_app/tables/users/${userId}`,
|
|
225
|
-
{ method: '
|
|
214
|
+
{ method: 'PATCH', body: { tier } } // PAY-2041: merge one field; PUT replaced the whole user row
|
|
226
215
|
);
|
|
227
216
|
|
|
228
217
|
if (!result.ok) {
|
|
@@ -235,7 +224,7 @@ export function createUsersHandler(config: AdminUsersHandlerConfig) {
|
|
|
235
224
|
if (action === 'update_credits' && userId && credits !== undefined) {
|
|
236
225
|
const result = await vibeServiceRequest<any>(
|
|
237
226
|
`/v1/collections/vibe_app/tables/users/${userId}`,
|
|
238
|
-
{ method: '
|
|
227
|
+
{ method: 'PATCH', body: { credits } } // PAY-2041: merge one field; PUT replaced the whole user row
|
|
239
228
|
);
|
|
240
229
|
|
|
241
230
|
if (!result.ok) {
|
|
@@ -248,6 +248,11 @@ export function createGetRecordHandler(config: AdminVibeHandlerConfig) {
|
|
|
248
248
|
/**
|
|
249
249
|
* PUT /api/admin/vibe/data/[collection]/[table]/[id]
|
|
250
250
|
* Update record (admin access)
|
|
251
|
+
*
|
|
252
|
+
* PAY-2041: this is deliberately a REPLACE. An editor save sends the whole record, and Core PUT sets the
|
|
253
|
+
* document data to the body, so a field the admin removed stays removed. Do not switch it to PATCH: a merge
|
|
254
|
+
* could never delete a key. Callers that hold only some fields (VibeTable.update, generic update, the
|
|
255
|
+
* users tier/credits writes) use PATCH instead. Ruled by BAPert 75440 (NightHawk 75437, Scout 75439 agree).
|
|
251
256
|
*/
|
|
252
257
|
export function createUpdateRecordHandler(config: AdminVibeHandlerConfig) {
|
|
253
258
|
return async function PUT(
|
package/src/vibe/client.ts
CHANGED
|
@@ -491,7 +491,8 @@ export class VibeTableDelegate<T extends VibeTableName> {
|
|
|
491
491
|
*/
|
|
492
492
|
async update(options: UpdateOptions<VibeTableType<T>>): Promise<VibeTableType<T>> {
|
|
493
493
|
const endpoint = this.tableEndpoint(options.where.id);
|
|
494
|
-
|
|
494
|
+
// PAY-2041: PATCH, not PUT. Core PUT REPLACES the document (UpdateAsync SET data = body), and `data` here is Partial, so PUT erased every field not named. PATCH merges (DocumentsController.Patch).
|
|
495
|
+
const response = await this.client.request<VibeDocumentWrapper>(endpoint, 'PATCH', options.data);
|
|
495
496
|
return this.requireRow(response.data, 'update');
|
|
496
497
|
}
|
|
497
498
|
|
|
@@ -693,7 +694,7 @@ export class VibeClient {
|
|
|
693
694
|
*/
|
|
694
695
|
private async requestViaProxy<T>(
|
|
695
696
|
endpoint: string,
|
|
696
|
-
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
|
697
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
697
698
|
body?: unknown,
|
|
698
699
|
authToken?: string
|
|
699
700
|
): Promise<VibeResponse<T>> {
|
|
@@ -788,7 +789,7 @@ export class VibeClient {
|
|
|
788
789
|
*/
|
|
789
790
|
async request<T>(
|
|
790
791
|
urlOrPath: string,
|
|
791
|
-
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
|
792
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
792
793
|
body?: unknown,
|
|
793
794
|
authToken?: string
|
|
794
795
|
): Promise<VibeResponse<T>> {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAY-1810: the ONE place the package builds Vibe /query filters and orderBy, in the shapes Core's VibeQueryBuilder accepts.
|
|
3
|
+
*
|
|
4
|
+
* Core accepts exactly three filter forms (PayEz.Vibe.Public.Api VibeQueryModels / VibeQueryBuilder):
|
|
5
|
+
* standard { field: { op: value } } with AND / OR arrays
|
|
6
|
+
* flat { field, operator, value }
|
|
7
|
+
* compound { operator: 'and' | 'or', filters: [ ...nodes ] } <- the key is `filters`, NOT `conditions`
|
|
8
|
+
* What goes wrong with the shapes this module replaces:
|
|
9
|
+
* - { operator, conditions } has no `filters` key, so Core reads `operator` / `conditions` as COLUMN names and returns ZERO rows
|
|
10
|
+
* (a broken search; for revoke-all, "revoked 0 sessions" reported as success).
|
|
11
|
+
* - operator 'like' is not a Core operator; in a flat filter it is DROPPED and the query returns ALL rows. Core's substring operator is
|
|
12
|
+
* `contains` (ILIKE, case-insensitive); `ilike` is case-insensitive EQUALS.
|
|
13
|
+
* - orderBy must be a { field: 'asc' | 'desc' } map (Core's model is Dictionary<string,string>); a bare string is rejected or ignored, never honoured.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type VibeOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'contains' | 'startsWith' | 'endsWith' | 'ilike' | 'in' | 'notIn';
|
|
17
|
+
|
|
18
|
+
export interface VibeFlatFilter {
|
|
19
|
+
field: string;
|
|
20
|
+
operator: VibeOperator;
|
|
21
|
+
value: unknown;
|
|
22
|
+
}
|
|
23
|
+
export interface VibeCompoundFilter {
|
|
24
|
+
operator: 'and' | 'or';
|
|
25
|
+
filters: VibeFilter[];
|
|
26
|
+
}
|
|
27
|
+
export type VibeFilter = VibeFlatFilter | VibeCompoundFilter;
|
|
28
|
+
|
|
29
|
+
export const eq = (field: string, value: unknown): VibeFlatFilter => ({ field, operator: 'eq', value });
|
|
30
|
+
export const gte = (field: string, value: unknown): VibeFlatFilter => ({ field, operator: 'gte', value });
|
|
31
|
+
export const lte = (field: string, value: unknown): VibeFlatFilter => ({ field, operator: 'lte', value });
|
|
32
|
+
/** Case-insensitive substring (Core `contains` = ILIKE). The value is the bare text: no % wildcards. */
|
|
33
|
+
export const contains = (field: string, value: string): VibeFlatFilter => ({ field, operator: 'contains', value });
|
|
34
|
+
|
|
35
|
+
/** AND of the given nodes: none -> undefined (no filter), one -> that node, several -> a compound with `filters`. */
|
|
36
|
+
export function allOf(nodes: Array<VibeFilter | undefined | null>): VibeFilter | undefined {
|
|
37
|
+
const f = nodes.filter((n): n is VibeFilter => !!n);
|
|
38
|
+
if (f.length === 0) return undefined;
|
|
39
|
+
return f.length === 1 ? f[0] : { operator: 'and', filters: f };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** OR of the given nodes: none -> undefined, one -> that node, several -> a compound with `filters`. */
|
|
43
|
+
export function anyOf(nodes: Array<VibeFilter | undefined | null>): VibeFilter | undefined {
|
|
44
|
+
const f = nodes.filter((n): n is VibeFilter => !!n);
|
|
45
|
+
if (f.length === 0) return undefined;
|
|
46
|
+
return f.length === 1 ? f[0] : { operator: 'or', filters: f };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** orderBy as Core binds it. */
|
|
50
|
+
export function orderByMap(field: string, direction: 'asc' | 'desc' = 'desc'): Record<string, 'asc' | 'desc'> {
|
|
51
|
+
return { [field]: direction };
|
|
52
|
+
}
|
package/src/vibe/generic.ts
CHANGED
|
@@ -224,7 +224,8 @@ export class GenericTableDelegate<T extends Record<string, unknown> = Record<str
|
|
|
224
224
|
async update(options: { where: { id: number }; data: Partial<T> }): Promise<T & { _vibe_doc_id?: number }> {
|
|
225
225
|
const path = vibeTablePath(this.collection, this.tableName, options.where.id);
|
|
226
226
|
const url = `${this.client.getBaseUrl()}${path}`;
|
|
227
|
-
|
|
227
|
+
// PAY-2041: PATCH merges; PUT would replace the whole document with this Partial.
|
|
228
|
+
const response = await this.client.request<VibeDocumentWrapper>(url, 'PATCH', options.data);
|
|
228
229
|
const unwrapped = unwrapVibeDocument<T>(response.data);
|
|
229
230
|
if (!unwrapped) {
|
|
230
231
|
throw new VibeServiceError('Failed to parse updated document');
|