Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/dal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { createSqliteFeatureFlagRepository } from './sqliteFeatureFlagRepository
import { createSqliteIdempotencyRepository } from './sqliteIdempotencyRepository.js';
import { createSqliteNotificationRepository } from './sqliteNotificationRepository.js';
import { createSqliteNotificationPreferencesRepository } from './sqliteNotificationPreferencesRepository.js';
import { createSqliteStatusRepository } from './sqliteStatusRepository.js';

import { runPgMigrations } from './pg/migrate.js';
import { createPgCampaignRepository } from './pg/pgCampaignRepository.js';
Expand Down Expand Up @@ -100,6 +101,7 @@ export async function createDal({
idempotency: createSqliteIdempotencyRepository({ db }),
notifications: createSqliteNotificationRepository({ db }),
notificationPreferences: createSqliteNotificationPreferencesRepository({ db }),
status: createSqliteStatusRepository({ db }),
db,
pgPool,
};
Expand Down
212 changes: 212 additions & 0 deletions backend/src/dal/sqliteStatusRepository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
export function createSqliteStatusRepository({ db }) {
return {
// Incidents
createIncident({ id, title, description, components, status, impact, createdAt, updatedAt }) {
const stmt = db.prepare(`
INSERT INTO status_incidents (id, title, description, components, status, impact, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(id, title, description, JSON.stringify(components), status, impact, createdAt, updatedAt);
return { id };
},

getIncident(id) {
const stmt = db.prepare(`
SELECT id, title, description, components, status, impact, created_at, updated_at
FROM status_incidents
WHERE id = ?
`);
const incident = stmt.get(id);
if (!incident) return null;
return {
...incident,
components: JSON.parse(incident.components),
};
},

listIncidents({ status, limit = 100, offset = 0 } = {}) {
let query = `
SELECT id, title, description, components, status, impact, created_at, updated_at
FROM status_incidents
`;
const params = [];

if (status) {
query += ` WHERE status = ?`;
params.push(status);
}

query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`;
params.push(limit, offset);

const stmt = db.prepare(query);
const incidents = stmt.all(...params);
return incidents.map((incident) => ({
...incident,
components: JSON.parse(incident.components),
}));
},

updateIncident({ id, title, description, components, status, impact, updatedAt }) {
const updates = [];
const params = [];

if (title !== undefined) {
updates.push('title = ?');
params.push(title);
}
if (description !== undefined) {
updates.push('description = ?');
params.push(description);
}
if (components !== undefined) {
updates.push('components = ?');
params.push(JSON.stringify(components));
}
if (status !== undefined) {
updates.push('status = ?');
params.push(status);
}
if (impact !== undefined) {
updates.push('impact = ?');
params.push(impact);
}

updates.push('updated_at = ?');
params.push(updatedAt);
params.push(id);

const stmt = db.prepare(`
UPDATE status_incidents
SET ${updates.join(', ')}
WHERE id = ?
`);
stmt.run(...params);
},

deleteIncident(id) {
const stmt = db.prepare(`DELETE FROM status_incidents WHERE id = ?`);
stmt.run(id);
},

// Incident Updates
createIncidentUpdate({ incidentId, status, message, timestamp }) {
const stmt = db.prepare(`
INSERT INTO status_incident_updates (incident_id, status, message, timestamp)
VALUES (?, ?, ?, ?)
`);
const result = stmt.run(incidentId, status, message, timestamp);
return { id: result.lastInsertRowid };
},

getIncidentUpdates(incidentId) {
const stmt = db.prepare(`
SELECT id, incident_id, status, message, timestamp
FROM status_incident_updates
WHERE incident_id = ?
ORDER BY timestamp ASC
`);
return stmt.all(incidentId);
},

// Maintenance
createMaintenance({ id, title, description, components, scheduledStart, scheduledEnd, createdAt }) {
const stmt = db.prepare(`
INSERT INTO status_maintenance (id, title, description, components, scheduled_start, scheduled_end, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(id, title, description, JSON.stringify(components), scheduledStart, scheduledEnd, createdAt);
return { id };
},

getMaintenance(id) {
const stmt = db.prepare(`
SELECT id, title, description, components, scheduled_start, scheduled_end, created_at
FROM status_maintenance
WHERE id = ?
`);
const maintenance = stmt.get(id);
if (!maintenance) return null;
return {
...maintenance,
components: JSON.parse(maintenance.components),
};
},

listMaintenance({ limit = 100, offset = 0 } = {}) {
const stmt = db.prepare(`
SELECT id, title, description, components, scheduled_start, scheduled_end, created_at
FROM status_maintenance
ORDER BY scheduled_start ASC
LIMIT ? OFFSET ?
`);
const maintenanceList = stmt.all(limit, offset);
return maintenanceList.map((maintenance) => ({
...maintenance,
components: JSON.parse(maintenance.components),
}));
},

deleteMaintenance(id) {
const stmt = db.prepare(`DELETE FROM status_maintenance WHERE id = ?`);
stmt.run(id);
},

// Subscribers
createSubscriber({ id, email, components, createdAt }) {
const stmt = db.prepare(`
INSERT INTO status_subscribers (id, email, components, created_at)
VALUES (?, ?, ?, ?)
`);
stmt.run(id, email, JSON.stringify(components), createdAt);
return { id };
},

getSubscriber(id) {
const stmt = db.prepare(`
SELECT id, email, components, created_at
FROM status_subscribers
WHERE id = ?
`);
const subscriber = stmt.get(id);
if (!subscriber) return null;
return {
...subscriber,
components: JSON.parse(subscriber.components),
};
},

getSubscriberByEmail(email) {
const stmt = db.prepare(`
SELECT id, email, components, created_at
FROM status_subscribers
WHERE email = ?
`);
const subscriber = stmt.get(email);
if (!subscriber) return null;
return {
...subscriber,
components: JSON.parse(subscriber.components),
};
},

listSubscribers({ limit = 100, offset = 0 } = {}) {
const stmt = db.prepare(`
SELECT id, email, components, created_at
FROM status_subscribers
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`);
const subscribers = stmt.all(limit, offset);
return subscribers.map((subscriber) => ({
...subscriber,
components: JSON.parse(subscriber.components),
}));
},

deleteSubscriber(id) {
const stmt = db.prepare(`DELETE FROM status_subscribers WHERE id = ?`);
stmt.run(id);
},
};
}
69 changes: 69 additions & 0 deletions backend/src/db/migrations/035_status_page.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
export const version = 35;
export const description = 'Status page tables for incidents, maintenance, and subscriptions';

export function up(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS status_incidents (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
components TEXT NOT NULL, -- JSON array of component IDs
status TEXT NOT NULL CHECK(status IN ('investigating', 'identified', 'monitoring', 'resolved')),
impact TEXT NOT NULL CHECK(impact IN ('none', 'minor', 'major', 'critical')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_status_incidents_status ON status_incidents(status);
CREATE INDEX IF NOT EXISTS idx_status_incidents_created_at ON status_incidents(created_at);

CREATE TABLE IF NOT EXISTS status_incident_updates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
incident_id TEXT NOT NULL,
status TEXT NOT NULL CHECK(status IN ('investigating', 'identified', 'monitoring', 'resolved')),
message TEXT NOT NULL,
timestamp TEXT NOT NULL,
FOREIGN KEY (incident_id) REFERENCES status_incidents(id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_status_incident_updates_incident_id ON status_incident_updates(incident_id);

CREATE TABLE IF NOT EXISTS status_maintenance (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
components TEXT NOT NULL, -- JSON array of component IDs
scheduled_start TEXT NOT NULL,
scheduled_end TEXT NOT NULL,
created_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_status_maintenance_scheduled_start ON status_maintenance(scheduled_start);
CREATE INDEX IF NOT EXISTS idx_status_maintenance_scheduled_end ON status_maintenance(scheduled_end);

CREATE TABLE IF NOT EXISTS status_subscribers (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
components TEXT NOT NULL, -- JSON array of component IDs to monitor
created_at TEXT NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_status_subscribers_email ON status_subscribers(email);
`);
}

export function down(db) {
db.exec(`
DROP INDEX IF EXISTS idx_status_subscribers_email;
DROP INDEX IF EXISTS idx_status_maintenance_scheduled_end;
DROP INDEX IF EXISTS idx_status_maintenance_scheduled_start;
DROP INDEX IF EXISTS idx_status_incident_updates_incident_id;
DROP INDEX IF EXISTS idx_status_incidents_created_at;
DROP INDEX IF EXISTS idx_status_incidents_status;

DROP TABLE IF EXISTS status_subscribers;
DROP TABLE IF EXISTS status_maintenance;
DROP TABLE IF EXISTS status_incident_updates;
DROP TABLE IF EXISTS status_incidents;
`);
}
5 changes: 4 additions & 1 deletion backend/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ import { createPruningJob } from './jobs/pruningJob.js';
import { createModerationService } from './moderation/moderationService.js';
import { createContentModerationMiddleware } from './middleware/contentModeration.js';
import createFaucetRoutes from './routes/faucet.js';
import createStatusRoutes from './routes/status.js';
import createStatusRoutes, { setStatusRepository, setEventIndexer, setRpcPool } from './routes/status.js';
import createWebhookRoutes from './routes/webhooks.js';

const DEFAULT_PORT = 3001;
Expand Down Expand Up @@ -2738,6 +2738,9 @@ export async function createApp(options = {}) {
app.use(`${API_V1_PREFIX}/webhooks`, createWebhookRoutes);

// #818 — Public status page + incident communication
setStatusRepository(dal.status);
setEventIndexer(eventIndexer);
setRpcPool(rpcPool);
app.use(`${API_V1_PREFIX}/status`, createStatusRoutes);

registerApiRoutes(API_V1_PREFIX);
Expand Down
Loading