Flesh out dd0c/portal: service CRUD, discovery API, Meilisearch search, data layer

- Service API: list (filtered by type/owner/lifecycle/tier), detail, upsert, delete, ownership summary
- Discovery API: trigger AWS/GitHub scans, scan history, staged update review (apply/reject)
- Search: Meilisearch full-text with PG ILIKE fallback, reindex endpoint
- Data layer: withTenant() RLS wrapper, Zod config with MEILI_URL/MEILI_KEY
- Fastify server entry point
This commit is contained in:
2026-03-01 03:05:55 +00:00
parent d85cdaa3e7
commit a17527dfa4
6 changed files with 322 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
import type { FastifyInstance } from 'fastify';
import pino from 'pino';
import { withTenant } from '../data/db.js';
const logger = pino({ name: 'api-discovery' });
export function registerDiscoveryRoutes(app: FastifyInstance) {
// Trigger AWS discovery scan
app.post('/api/v1/discovery/aws', async (req, reply) => {
const tenantId = (req as any).tenantId;
// TODO: Enqueue scan job (Redis queue or direct execution)
logger.info({ tenantId }, 'AWS discovery scan triggered');
return reply.status(202).send({ status: 'scan_queued', scanner: 'aws' });
});
// Trigger GitHub discovery scan
app.post('/api/v1/discovery/github', async (req, reply) => {
const tenantId = (req as any).tenantId;
logger.info({ tenantId }, 'GitHub discovery scan triggered');
return reply.status(202).send({ status: 'scan_queued', scanner: 'github' });
});
// Get scan history
app.get('/api/v1/discovery/history', async (req, reply) => {
const tenantId = (req as any).tenantId;
const result = await withTenant(tenantId, async (client) => {
return client.query('SELECT * FROM scan_history ORDER BY started_at DESC LIMIT 20');
});
return { scans: result.rows };
});
// List staged updates (from partial scans)
app.get('/api/v1/discovery/staged', async (req, reply) => {
const tenantId = (req as any).tenantId;
const result = await withTenant(tenantId, async (client) => {
return client.query("SELECT * FROM staged_updates WHERE status = 'pending' ORDER BY created_at DESC");
});
return { staged: result.rows };
});
// Apply or reject staged update
app.post('/api/v1/discovery/staged/:id/:action', async (req, reply) => {
const { id, action } = req.params as { id: string; action: string };
const tenantId = (req as any).tenantId;
if (action !== 'apply' && action !== 'reject') {
return reply.status(400).send({ error: 'Action must be apply or reject' });
}
const newStatus = action === 'apply' ? 'applied' : 'rejected';
await withTenant(tenantId, async (client) => {
await client.query('UPDATE staged_updates SET status = $1 WHERE id = $2', [newStatus, id]);
// TODO: If applied, merge changes into services table
});
return { status: newStatus };
});
}

View File

@@ -0,0 +1,77 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { MeiliSearch } from 'meilisearch';
import pino from 'pino';
import { config } from '../config/index.js';
const logger = pino({ name: 'api-search' });
let meili: MeiliSearch | null = null;
function getMeili(): MeiliSearch {
if (!meili) {
meili = new MeiliSearch({ host: config.MEILI_URL, apiKey: config.MEILI_KEY || undefined });
}
return meili;
}
const searchQuerySchema = z.object({
q: z.string().min(1).max(500),
limit: z.coerce.number().min(1).max(50).default(20),
offset: z.coerce.number().min(0).default(0),
filter: z.string().optional(), // Meilisearch filter syntax
});
export function registerSearchRoutes(app: FastifyInstance) {
// Full-text search across services
app.get('/api/v1/search', async (req, reply) => {
const query = searchQuerySchema.parse(req.query);
const tenantId = (req as any).tenantId;
try {
const index = getMeili().index(`services_${tenantId}`);
const results = await index.search(query.q, {
limit: query.limit,
offset: query.offset,
filter: query.filter,
attributesToHighlight: ['name', 'description', 'owner'],
});
return {
hits: results.hits,
total: results.estimatedTotalHits,
query: query.q,
processingTimeMs: results.processingTimeMs,
};
} catch (err) {
logger.warn({ error: (err as Error).message }, 'Meilisearch unavailable — falling back to PG');
// Fallback: basic ILIKE search on PostgreSQL
const { withTenant } = await import('../data/db.js');
const result = await withTenant(tenantId, async (client) => {
return client.query(
`SELECT * FROM services WHERE name ILIKE $1 OR description ILIKE $1 OR owner ILIKE $1 LIMIT $2 OFFSET $3`,
[`%${query.q}%`, query.limit, query.offset],
);
});
return { hits: result.rows, total: result.rowCount, query: query.q, fallback: true };
}
});
// Sync services to Meilisearch index
app.post('/api/v1/search/reindex', async (req, reply) => {
const tenantId = (req as any).tenantId;
const { withTenant } = await import('../data/db.js');
const services = await withTenant(tenantId, async (client) => {
return client.query('SELECT * FROM services');
});
const index = getMeili().index(`services_${tenantId}`);
await index.addDocuments(services.rows.map(s => ({ ...s, id: s.id })));
logger.info({ tenantId, count: services.rowCount }, 'Reindex triggered');
return { status: 'reindexing', documents: services.rowCount };
});
}

View File

@@ -0,0 +1,114 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { withTenant } from '../data/db.js';
const listQuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
type: z.string().optional(),
owner: z.string().optional(),
lifecycle: z.enum(['active', 'deprecated', 'decommissioned']).optional(),
tier: z.enum(['critical', 'high', 'medium', 'low']).optional(),
});
const upsertServiceSchema = z.object({
name: z.string().min(1).max(200),
type: z.string().default('unknown'),
owner: z.string().default('unknown'),
description: z.string().max(2000).optional(),
tier: z.enum(['critical', 'high', 'medium', 'low']).default('medium'),
lifecycle: z.enum(['active', 'deprecated', 'decommissioned']).default('active'),
links: z.record(z.string()).default({}),
tags: z.record(z.string()).default({}),
});
export function registerServiceRoutes(app: FastifyInstance) {
// List services
app.get('/api/v1/services', async (req, reply) => {
const query = listQuerySchema.parse(req.query);
const tenantId = (req as any).tenantId;
const offset = (query.page - 1) * query.limit;
const result = await withTenant(tenantId, async (client) => {
let sql = 'SELECT * FROM services WHERE 1=1';
const params: any[] = [];
let idx = 1;
if (query.type) { sql += ` AND type = $${idx++}`; params.push(query.type); }
if (query.owner) { sql += ` AND owner = $${idx++}`; params.push(query.owner); }
if (query.lifecycle) { sql += ` AND lifecycle = $${idx++}`; params.push(query.lifecycle); }
if (query.tier) { sql += ` AND tier = $${idx++}`; params.push(query.tier); }
sql += ` ORDER BY name LIMIT $${idx++} OFFSET $${idx++}`;
params.push(query.limit, offset);
return client.query(sql, params);
});
return { services: result.rows, page: query.page, limit: query.limit };
});
// Get service detail
app.get('/api/v1/services/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const tenantId = (req as any).tenantId;
const result = await withTenant(tenantId, async (client) => {
return client.query('SELECT * FROM services WHERE id = $1', [id]);
});
if (!result.rows[0]) return reply.status(404).send({ error: 'Not found' });
return { service: result.rows[0] };
});
// Create/update service (manual entry)
app.put('/api/v1/services', async (req, reply) => {
const body = upsertServiceSchema.parse(req.body);
const tenantId = (req as any).tenantId;
const result = await withTenant(tenantId, async (client) => {
return client.query(
`INSERT INTO services (tenant_id, name, type, owner, owner_source, description, tier, lifecycle, links, tags)
VALUES ($1, $2, $3, $4, 'config', $5, $6, $7, $8, $9)
ON CONFLICT (tenant_id, name) DO UPDATE SET
type = EXCLUDED.type, owner = EXCLUDED.owner, owner_source = 'config',
description = EXCLUDED.description, tier = EXCLUDED.tier, lifecycle = EXCLUDED.lifecycle,
links = EXCLUDED.links, tags = EXCLUDED.tags, updated_at = now()
RETURNING *`,
[tenantId, body.name, body.type, body.owner, body.description, body.tier, body.lifecycle, JSON.stringify(body.links), JSON.stringify(body.tags)],
);
});
return reply.status(201).send({ service: result.rows[0] });
});
// Delete service
app.delete('/api/v1/services/:id', async (req, reply) => {
const { id } = req.params as { id: string };
const tenantId = (req as any).tenantId;
await withTenant(tenantId, async (client) => {
await client.query('DELETE FROM services WHERE id = $1', [id]);
});
return { status: 'deleted' };
});
// Ownership summary (who owns what)
app.get('/api/v1/ownership', async (req, reply) => {
const tenantId = (req as any).tenantId;
const result = await withTenant(tenantId, async (client) => {
return client.query(`
SELECT owner, owner_source, COUNT(*)::int as service_count,
array_agg(name ORDER BY name) as services
FROM services
WHERE lifecycle = 'active'
GROUP BY owner, owner_source
ORDER BY service_count DESC
`);
});
return { owners: result.rows };
});
}

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
const envSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().default('postgresql://localhost:5432/dd0c_portal'),
REDIS_URL: z.string().default('redis://localhost:6379'),
MEILI_URL: z.string().default('http://localhost:7700'),
MEILI_KEY: z.string().default(''),
JWT_SECRET: z.string().min(32).default('dev-secret-change-me-in-production!!'),
CORS_ORIGIN: z.string().default('*'),
LOG_LEVEL: z.string().default('info'),
});
export const config = envSchema.parse(process.env);
export type Config = z.infer<typeof envSchema>;

View File

@@ -0,0 +1,24 @@
import pg from 'pg';
import pino from 'pino';
import { config } from '../config/index.js';
const logger = pino({ name: 'data' });
export const pool = new pg.Pool({ connectionString: config.DATABASE_URL });
export async function withTenant<T>(tenantId: string, fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`);
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
await client.query('RESET app.tenant_id');
client.release();
}
}

View File

@@ -0,0 +1,29 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import pino from 'pino';
import { config } from './config/index.js';
import { registerServiceRoutes } from './api/services.js';
import { registerDiscoveryRoutes } from './api/discovery.js';
import { registerSearchRoutes } from './api/search.js';
const logger = pino({ name: 'dd0c-portal', level: config.LOG_LEVEL });
const app = Fastify({ logger: true });
await app.register(cors, { origin: config.CORS_ORIGIN });
await app.register(helmet);
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-portal' }));
registerServiceRoutes(app);
registerDiscoveryRoutes(app);
registerSearchRoutes(app);
try {
await app.listen({ port: config.PORT, host: '0.0.0.0' });
logger.info({ port: config.PORT }, 'dd0c/portal started');
} catch (err) {
logger.fatal(err, 'Failed to start');
process.exit(1);
}