|
1 | | -import { orgReadRateLimiter, orgWriteRateLimiter } from '../middleware/rateLimiter.js' |
| 1 | +import { orgReadRateLimiter } from '../middleware/rateLimiter.js' |
2 | 2 | import { Router, Request, Response } from 'express' |
3 | 3 | import { authenticate } from '../middleware/auth.js' |
4 | 4 | import { requireOrgAccess } from '../middleware/orgAuth.js' |
5 | 5 | 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' |
7 | 7 | import { vaults } from './vaults.js' |
| 8 | +import db from '../db/index.js' |
8 | 9 |
|
9 | 10 | export const orgVaultsRouter = Router() |
10 | 11 |
|
@@ -33,3 +34,170 @@ orgVaultsRouter.get( |
33 | 34 | res.json(paginatedResult) |
34 | 35 | } |
35 | 36 | ) |
| 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