Skip to content

Commit cddbe6d

Browse files
committed
Merge pull request #835
2 parents b441cd4 + 70464fb commit cddbe6d

4 files changed

Lines changed: 1056 additions & 2 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Add full-text search support to the vaults table.
3+
*
4+
* Creates a tsvector column (`search_vector`) populated from the vault's
5+
* `creator` and `verifier` fields and maintained automatically via a trigger.
6+
* A GIN index on that column enables fast, index-only FTS queries.
7+
*
8+
* Security note: no row data is logged at any step.
9+
*/
10+
11+
exports.config = { transaction: false }
12+
13+
exports.up = async function up(knex) {
14+
// 1. Add the tsvector column (nullable initially so existing rows are valid)
15+
await knex.raw(`
16+
ALTER TABLE vaults
17+
ADD COLUMN IF NOT EXISTS search_vector tsvector
18+
`)
19+
20+
// 2. Back-fill existing rows
21+
await knex.raw(`
22+
UPDATE vaults
23+
SET search_vector =
24+
to_tsvector('simple', coalesce(creator, '') || ' ' || coalesce(verifier, ''))
25+
`)
26+
27+
// 3. Create GIN index for fast full-text lookups
28+
await knex.raw(`
29+
CREATE INDEX IF NOT EXISTS idx_vaults_search_vector
30+
ON vaults USING GIN (search_vector)
31+
`)
32+
33+
// 4. Trigger function: keep search_vector in sync on INSERT / UPDATE
34+
await knex.raw(`
35+
CREATE OR REPLACE FUNCTION vaults_search_vector_update()
36+
RETURNS trigger LANGUAGE plpgsql AS $$
37+
BEGIN
38+
NEW.search_vector :=
39+
to_tsvector('simple',
40+
coalesce(NEW.creator, '') || ' ' ||
41+
coalesce(NEW.verifier, '')
42+
);
43+
RETURN NEW;
44+
END;
45+
$$
46+
`)
47+
48+
// 5. Attach trigger to the table (drop-if-exists for idempotency)
49+
await knex.raw(`
50+
DROP TRIGGER IF EXISTS trg_vaults_search_vector ON vaults
51+
`)
52+
await knex.raw(`
53+
CREATE TRIGGER trg_vaults_search_vector
54+
BEFORE INSERT OR UPDATE OF creator, verifier
55+
ON vaults
56+
FOR EACH ROW EXECUTE FUNCTION vaults_search_vector_update()
57+
`)
58+
}
59+
60+
exports.down = async function down(knex) {
61+
await knex.raw(`DROP TRIGGER IF EXISTS trg_vaults_search_vector ON vaults`)
62+
await knex.raw(`DROP FUNCTION IF EXISTS vaults_search_vector_update()`)
63+
await knex.raw(`DROP INDEX IF EXISTS idx_vaults_search_vector`)
64+
await knex.raw(`ALTER TABLE vaults DROP COLUMN IF EXISTS search_vector`)
65+
}

docs/vaults-api.md

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,3 +328,129 @@ When `onChain.mode` is `"submit"`, the backend polls `getTransaction` after send
328328
### SorobanTimeoutError
329329

330330
Thrown when the deadline is exceeded. Carries `txHash`, `elapsedMs`, `code: "SOROBAN_TIMEOUT"`, `status: 504`. Surfaced in the submission response as `status: "error"`.
331+
332+
333+
---
334+
335+
## Org-Scoped Vault Search
336+
337+
`GET /api/orgs/:orgId/vaults/search`
338+
339+
Search an organization's vaults using full-text matching and structured filters.
340+
Results are cursor-paginated for stable, consistent paging across large result sets.
341+
342+
### Authentication & Authorization
343+
344+
Requires a valid JWT in the `Authorization: Bearer <token>` header.
345+
The caller must be a member of the target organization (role: `owner`, `admin`, or `member`).
346+
347+
### Path Parameters
348+
349+
| Parameter | Type | Description |
350+
|-----------|--------|----------------------------------|
351+
| `orgId` | string | UUID of the organization to search |
352+
353+
### Query Parameters
354+
355+
| Parameter | Type | Required | Description |
356+
|--------------|--------|----------|-------------|
357+
| `q` | string | No | Full-text search term. Matches the `creator` and `verifier` fields via a PostgreSQL GIN/tsvector index. Falls back to `ILIKE` if the FTS column is unavailable. Max 200 characters after sanitization. |
358+
| `status` | string | No | Exact status filter. Accepted values: `draft`, `active`, `completed`, `failed`, `cancelled`. |
359+
| `verifier` | string | No | Exact verifier Stellar address match. |
360+
| `amount_min` | string | No | Minimum vault amount (inclusive). |
361+
| `amount_max` | string | No | Maximum vault amount (inclusive). |
362+
| `date_from` | string | No | Minimum `created_at` timestamp (ISO 8601, inclusive). |
363+
| `date_to` | string | No | Maximum `created_at` timestamp (ISO 8601, inclusive). |
364+
| `cursor` | string | No | Opaque cursor from the previous page's `next_cursor` field. |
365+
| `limit` | number | No | Page size. Range: 1–100. Default: 20. |
366+
367+
### Response
368+
369+
```json
370+
{
371+
"data": [
372+
{
373+
"id": "vault-uuid",
374+
"creator": "GCREATOR...",
375+
"verifier": "GVERIFIER...",
376+
"amount": "1000",
377+
"status": "active",
378+
"organization_id": "org-uuid",
379+
"start_date": "2025-01-01T00:00:00.000Z",
380+
"end_date": "2025-12-31T00:00:00.000Z",
381+
"created_at": "2025-03-01T12:00:00.000Z",
382+
"updated_at": "2025-03-01T12:00:00.000Z"
383+
}
384+
],
385+
"pagination": {
386+
"limit": 20,
387+
"cursor": "<current-cursor-or-null>",
388+
"next_cursor": "<opaque-base64url-string>",
389+
"has_more": true,
390+
"count": 20
391+
}
392+
}
393+
```
394+
395+
When `has_more` is `false`, `next_cursor` is absent.
396+
397+
### Cursor Pagination
398+
399+
Results are sorted `created_at DESC, id DESC` for stability. To page through results:
400+
401+
1. Make the initial request without a `cursor`.
402+
2. If `pagination.has_more` is `true`, pass `pagination.next_cursor` as the `cursor` query parameter in the next request.
403+
3. Repeat until `has_more` is `false`.
404+
405+
Cursors encode a `(created_at, id)` tuple and are opaque base64url strings. Do not construct or parse them — treat them as black boxes.
406+
407+
### Full-Text Search Index
408+
409+
The `q` parameter is backed by a PostgreSQL GIN index on a `tsvector` column (`search_vector`) covering `creator` and `verifier`. The column is maintained by a database trigger (`trg_vaults_search_vector`) that runs `BEFORE INSERT OR UPDATE` on those columns.
410+
411+
Migration: `db/migrations/20260627000000_add_vault_fts_index.cjs`
412+
413+
Prefix-match semantics (`term:*`) are used so partial terms (e.g. `q=alice`) still match.
414+
415+
### Error Responses
416+
417+
| Status | Condition |
418+
|--------|-----------|
419+
| 400 | `cursor` value is not a valid opaque cursor. Body: `{ "error": "Invalid cursor" }` |
420+
| 401 | Missing or invalid Authorization token. |
421+
| 403 | Caller is not a member of the specified organization. |
422+
| 404 | Organization does not exist. |
423+
| 500 | Unexpected server error. |
424+
425+
### Security Notes
426+
427+
- **Tenant isolation**: Every query is scoped by `WHERE organization_id = :orgId`. This filter runs inside the database engine and cannot be bypassed by client-supplied parameters.
428+
- **Soft-delete awareness**: Vaults with `deleted_at IS NOT NULL` are excluded automatically.
429+
- **Injection safety**: The `q` parameter is sanitised (only word characters, spaces, dots, hyphens and underscores allowed) before being interpolated into a tsquery. All remaining parameters are applied through Knex's parameterized query API — no string concatenation.
430+
- **Rate limiting**: The endpoint shares the organization read rate limiter (`orgReadRateLimiter`), configurable via `ORG_RATE_LIMIT_MAX` / `ORG_RATE_LIMIT_WINDOW_MS`.
431+
432+
### Examples
433+
434+
**Search by text:**
435+
```
436+
GET /api/orgs/org-uuid/vaults/search?q=alice
437+
```
438+
439+
**Filter by status and capital range:**
440+
```
441+
GET /api/orgs/org-uuid/vaults/search?status=active&amount_min=500&amount_max=5000
442+
```
443+
444+
**Combined text + date range:**
445+
```
446+
GET /api/orgs/org-uuid/vaults/search?q=bob&date_from=2025-01-01T00:00:00Z&date_to=2025-06-30T23:59:59Z
447+
```
448+
449+
**Paginate through results:**
450+
```
451+
# Page 1
452+
GET /api/orgs/org-uuid/vaults/search?limit=10
453+
454+
# Page 2 (using next_cursor from page 1 response)
455+
GET /api/orgs/org-uuid/vaults/search?limit=10&cursor=eyJ0aW1lc3RhbXAiO...
456+
```

src/routes/orgVaults.ts

Lines changed: 170 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { orgReadRateLimiter, orgWriteRateLimiter } from '../middleware/rateLimiter.js'
1+
import { orgReadRateLimiter } from '../middleware/rateLimiter.js'
22
import { Router, Request, Response } from 'express'
33
import { authenticate } from '../middleware/auth.js'
44
import { requireOrgAccess } from '../middleware/orgAuth.js'
55
import { queryParser } from '../middleware/queryParser.js'
6-
import { applyFilters, applySort, paginateArray } from '../utils/pagination.js'
6+
import { applyFilters, applySort, paginateArray, encodeCursor, decodeCursor } from '../utils/pagination.js'
77
import { vaults } from './vaults.js'
8+
import db from '../db/index.js'
89

910
export const orgVaultsRouter = Router()
1011

@@ -33,3 +34,170 @@ orgVaultsRouter.get(
3334
res.json(paginatedResult)
3435
}
3536
)
37+
38+
/**
39+
* GET /api/orgs/:orgId/vaults/search
40+
*
41+
* Org-scoped vault search with full-text matching and structured filters.
42+
* Results are cursor-paginated for stable, consistent paging.
43+
*
44+
* Query parameters:
45+
* q - Full-text search term (matches creator + verifier via tsvector/GIN index,
46+
* falls back to ILIKE when the DB has no tsvector column yet)
47+
* status - Exact status filter: draft | active | completed | failed | cancelled
48+
* verifier - Exact verifier address filter
49+
* amount_min - Minimum vault amount (inclusive)
50+
* amount_max - Maximum vault amount (inclusive)
51+
* date_from - Minimum created_at (ISO 8601 inclusive)
52+
* date_to - Maximum created_at (ISO 8601 inclusive)
53+
* cursor - Opaque pagination cursor from a previous response
54+
* limit - Page size (1–100, default 20)
55+
*/
56+
orgVaultsRouter.get(
57+
'/:orgId/vaults/search',
58+
authenticate,
59+
requireOrgAccess('owner', 'admin', 'member'),
60+
orgReadRateLimiter,
61+
queryParser({
62+
allowedSortFields: ['created_at', 'amount', 'end_date', 'status'],
63+
allowedFilterFields: ['status', 'verifier', 'amount_min', 'amount_max', 'date_from', 'date_to'],
64+
}),
65+
async (req: Request, res: Response): Promise<void> => {
66+
const { orgId } = req.params
67+
68+
// ── Raw search term ─────────────────────────────────────────────────────
69+
// Strip to plain text — no special characters that could be meaningful
70+
// to tsvector/ILIKE beyond the literal token.
71+
const rawQ = typeof req.query.q === 'string' ? req.query.q.trim() : ''
72+
// Sanitise: keep only alphanumeric, spaces, dots, hyphens, underscores
73+
const q = rawQ.replace(/[^\w\s.\-]/g, '').substring(0, 200)
74+
75+
// ── Pagination ──────────────────────────────────────────────────────────
76+
const limit = Math.min(100, Math.max(1, parseInt(String(req.query.limit ?? '20'))))
77+
const rawCursor = typeof req.query.cursor === 'string' ? req.query.cursor : undefined
78+
79+
try {
80+
// ── Base query — always scoped to the org and not soft-deleted ────────
81+
let query = db('vaults')
82+
.where('organization_id', orgId)
83+
.whereNull('deleted_at')
84+
85+
// ── Full-text search ─────────────────────────────────────────────────
86+
if (q) {
87+
// Check whether the tsvector column exists (migration may not have run yet)
88+
const hasFtsColumn = await db('information_schema.columns')
89+
.where({
90+
table_schema: 'public',
91+
table_name: 'vaults',
92+
column_name: 'search_vector',
93+
})
94+
.first()
95+
.then(Boolean)
96+
97+
if (hasFtsColumn) {
98+
// GIN index path — injection-safe: q is bound via knex parameterisation
99+
query = query.whereRaw(
100+
`search_vector @@ to_tsquery('simple', ?)`,
101+
[q.split(/\s+/).filter(Boolean).map(t => `${t}:*`).join(' & ')],
102+
)
103+
} else {
104+
// Fallback ILIKE path (slower, but safe until migration runs)
105+
query = query.where(function () {
106+
this.where('creator', 'ilike', `%${q}%`)
107+
.orWhere('verifier', 'ilike', `%${q}%`)
108+
})
109+
}
110+
}
111+
112+
// ── Structured filters ───────────────────────────────────────────────
113+
const filters = req.filters ?? {}
114+
115+
if (filters.status) {
116+
const status = Array.isArray(filters.status) ? filters.status[0] : filters.status
117+
query = query.where('status', status)
118+
}
119+
120+
if (filters.verifier) {
121+
const verifier = Array.isArray(filters.verifier) ? filters.verifier[0] : filters.verifier
122+
query = query.where('verifier', verifier)
123+
}
124+
125+
if (filters.amount_min) {
126+
const min = Array.isArray(filters.amount_min) ? filters.amount_min[0] : filters.amount_min
127+
query = query.where('amount', '>=', min)
128+
}
129+
130+
if (filters.amount_max) {
131+
const max = Array.isArray(filters.amount_max) ? filters.amount_max[0] : filters.amount_max
132+
query = query.where('amount', '<=', max)
133+
}
134+
135+
if (filters.date_from) {
136+
const from = Array.isArray(filters.date_from) ? filters.date_from[0] : filters.date_from
137+
query = query.where('created_at', '>=', new Date(from))
138+
}
139+
140+
if (filters.date_to) {
141+
const to = Array.isArray(filters.date_to) ? filters.date_to[0] : filters.date_to
142+
query = query.where('created_at', '<=', new Date(to))
143+
}
144+
145+
// ── Cursor pagination ────────────────────────────────────────────────
146+
// Stable sort: (created_at DESC, id DESC) — matches encodeCursor/decodeCursor contract
147+
if (rawCursor) {
148+
try {
149+
const { timestamp, id } = decodeCursor(rawCursor)
150+
query = query.where(function () {
151+
this.where('created_at', '<', timestamp)
152+
.orWhere(function () {
153+
this.where('created_at', '=', timestamp).andWhere('id', '<', id)
154+
})
155+
})
156+
} catch {
157+
res.status(400).json({ error: 'Invalid cursor' })
158+
return
159+
}
160+
}
161+
162+
// Enforce stable ordering
163+
query = query.orderBy('created_at', 'desc').orderBy('id', 'desc')
164+
165+
// Fetch limit + 1 to detect whether a next page exists
166+
const rows = await query.limit(limit + 1).select(
167+
'id',
168+
'creator',
169+
'verifier',
170+
'amount',
171+
'status',
172+
'organization_id',
173+
'start_date',
174+
'end_date',
175+
'created_at',
176+
'updated_at',
177+
)
178+
179+
const hasMore = rows.length > limit
180+
const results = rows.slice(0, limit)
181+
182+
let nextCursor: string | undefined
183+
if (hasMore && results.length > 0) {
184+
const last = results[results.length - 1]
185+
nextCursor = encodeCursor(new Date(last.created_at), last.id)
186+
}
187+
188+
res.json({
189+
data: results,
190+
pagination: {
191+
limit,
192+
cursor: rawCursor,
193+
next_cursor: nextCursor,
194+
has_more: hasMore,
195+
count: results.length,
196+
},
197+
})
198+
} catch (error) {
199+
console.error('Error searching org vaults:', error)
200+
res.status(500).json({ error: 'Internal server error' })
201+
}
202+
},
203+
)

0 commit comments

Comments
 (0)