Skip to content

Commit c6d1f5e

Browse files
hubyrodclaude
andcommitted
Move calendar config from hardcoded values to DB
Add calendar_users table and load calendar users + auth token from the database instead of hardcoding in src/config.ts. Seed script gracefully skips entries when dependencies are missing. - Add migration 002_calendar_users - Add scripts/seed-calendar.ts for calendar-specific seeding - Add getCalendarUsers(), getAuthToken() to src/db.ts - Load calendar config from DB in instrumentation.ts - Show calendar users on /config page - Add DB query tests in src/db.test.ts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7078153 commit c6d1f5e

8 files changed

Lines changed: 274 additions & 33 deletions

File tree

app/config/page.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getAuthTokens, getGroups, getRoutes } from "@/src/db";
1+
import { getAuthTokens, getGroups, getRoutes, getCalendarUsers } from "@/src/db";
22

33
export const dynamic = "force-dynamic";
44

@@ -50,10 +50,11 @@ const streamBadge: React.CSSProperties = {
5050
};
5151

5252
export default async function ConfigPage() {
53-
const [authTokens, groups, routes] = await Promise.all([
53+
const [authTokens, groups, routes, calendarUsers] = await Promise.all([
5454
getAuthTokens(),
5555
getGroups(),
5656
getRoutes(),
57+
getCalendarUsers(),
5758
]);
5859

5960
return (
@@ -147,6 +148,31 @@ export default async function ConfigPage() {
147148
</tbody>
148149
</table>
149150
</div>
151+
{calendarUsers.length > 0 && (
152+
<div style={section}>
153+
<h2 style={{ fontSize: "1.125rem", fontWeight: 600, marginBottom: "0.75rem" }}>
154+
Calendar Users
155+
</h2>
156+
<table style={table}>
157+
<thead>
158+
<tr>
159+
<th style={th}>Name</th>
160+
<th style={th}>Calendar ID</th>
161+
<th style={th}>Target ID</th>
162+
</tr>
163+
</thead>
164+
<tbody>
165+
{calendarUsers.map((u) => (
166+
<tr key={u.name}>
167+
<td style={td}>{u.name}</td>
168+
<td style={{ ...td, ...mono }}>{u.calendarId}</td>
169+
<td style={{ ...td, ...mono }}>{u.targetId}</td>
170+
</tr>
171+
))}
172+
</tbody>
173+
</table>
174+
</div>
175+
)}
150176
</main>
151177
);
152178
}

instrumentation.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { join } from "node:path";
22
import { loadAppConfig } from "@/src/config";
3-
import { runMigrations, getAllRoutes } from "@/src/db";
3+
import { runMigrations, getAllRoutes, getCalendarUsers, getAuthToken } from "@/src/db";
44
import { validateConnection, type SlashworkConnection } from "@/src/slashwork";
55
import { validateCalendarAuth } from "@/src/calendar/auth";
66
import { startCalendarPoller } from "@/src/calendar/poller";
@@ -36,15 +36,35 @@ export async function register() {
3636
log("error", `Failed to load routes from DB: ${err}`);
3737
}
3838

39-
if (config.calendar) {
40-
const cal = config.calendar;
39+
if (process.env.GOOGLE_SERVICE_ACCOUNT_KEY) {
40+
try {
41+
const calendarAuthToken = await getAuthToken("google_calendar");
42+
const calendarUsers = await getCalendarUsers();
4143

42-
validateCalendarAuth(cal.serviceAccountKey).then(
43-
() => {
44-
log("info", "Calendar: Google auth validated");
45-
startCalendarPoller(cal, config.slashwork.graphqlUrl, log);
46-
},
47-
(err) => log("error", `Calendar: auth failed — ${err}`),
48-
);
44+
if (!calendarAuthToken) {
45+
log("error", 'Calendar: auth_token "google_calendar" not found in DB');
46+
return;
47+
}
48+
if (calendarUsers.length === 0) {
49+
log("warn", "Calendar: no users configured in DB");
50+
return;
51+
}
52+
53+
const calendarConfig = {
54+
serviceAccountKey: process.env.GOOGLE_SERVICE_ACCOUNT_KEY,
55+
authToken: calendarAuthToken,
56+
users: calendarUsers,
57+
};
58+
59+
validateCalendarAuth(calendarConfig.serviceAccountKey).then(
60+
() => {
61+
log("info", "Calendar: Google auth validated");
62+
startCalendarPoller(calendarConfig, config.slashwork.graphqlUrl, log);
63+
},
64+
(err) => log("error", `Calendar: auth failed — ${err}`),
65+
);
66+
} catch (err) {
67+
log("error", `Calendar: failed to load config from DB: ${err}`);
68+
}
4969
}
5070
}

migrations/002_calendar_users.sql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
CREATE TABLE calendar_users (
2+
name TEXT PRIMARY KEY,
3+
calendar_id TEXT NOT NULL,
4+
target_id TEXT NOT NULL
5+
);

scripts/seed-calendar.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Seeds calendar-related data into the database.
3+
* Run migrations first: bun scripts/migrate.ts
4+
*/
5+
import pg from "pg";
6+
7+
const connectionString = process.env.POSTGRESQL_ADDON_URI;
8+
if (!connectionString) {
9+
console.error("POSTGRESQL_ADDON_URI environment variable is required");
10+
process.exit(1);
11+
}
12+
13+
const pool = new pg.Pool({ connectionString });
14+
15+
async function seed() {
16+
console.log("Seeding calendar data...");
17+
18+
// Seed calendar auth token
19+
const calendarToken = process.env.SLASHWORK_AUTH_TOKEN_GOOGLE_CALENDAR;
20+
if (calendarToken) {
21+
await pool.query(
22+
`INSERT INTO auth_tokens (name, token) VALUES ($1, $2)
23+
ON CONFLICT (name) DO UPDATE SET token = EXCLUDED.token`,
24+
["google_calendar", calendarToken],
25+
);
26+
console.log(' auth_token "google_calendar" seeded');
27+
} else {
28+
console.warn(" Skipping auth_token: SLASHWORK_AUTH_TOKEN_GOOGLE_CALENDAR not set");
29+
}
30+
31+
// Seed calendar users
32+
const users = [
33+
{ name: "Hugo", calendarId: "hugo@skiplabs.io", targetId: "g_cR_HOoSUCphBLt7gktCEyi" },
34+
];
35+
36+
for (const u of users) {
37+
await pool.query(
38+
`INSERT INTO calendar_users (name, calendar_id, target_id) VALUES ($1, $2, $3)
39+
ON CONFLICT (name) DO UPDATE SET calendar_id = EXCLUDED.calendar_id, target_id = EXCLUDED.target_id`,
40+
[u.name, u.calendarId, u.targetId],
41+
);
42+
console.log(` calendar_user "${u.name}" seeded`);
43+
}
44+
45+
console.log("Done.");
46+
await pool.end();
47+
}
48+
49+
seed().catch((err) => {
50+
console.error("Seed failed:", err);
51+
process.exit(1);
52+
});

scripts/setup-db.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,37 +22,48 @@ async function seed() {
2222
{ name: "skip", envVar: "SLASHWORK_AUTH_TOKEN_SKIP" },
2323
];
2424

25+
const seededTokens = new Set<string>();
2526
for (const { name, envVar } of tokenEntries) {
2627
const token = process.env[envVar];
2728
if (!token) {
28-
console.warn(`Skipping auth_token "${name}": ${envVar} not set`);
29+
console.warn(` Skipping auth_token "${name}": ${envVar} not set`);
2930
continue;
3031
}
3132
await pool.query(
3233
`INSERT INTO auth_tokens (name, token) VALUES ($1, $2)
3334
ON CONFLICT (name) DO UPDATE SET token = EXCLUDED.token`,
3435
[name, token],
3536
);
37+
seededTokens.add(name);
3638
console.log(` auth_token "${name}" seeded`);
3739
}
3840

39-
// Seed groups
41+
// Seed groups (skip if auth token wasn't seeded)
4042
const groups = [
4143
{ name: "skipper", slashworkId: "g_aVypv5BKvHiKP3tikjHjtj", authToken: "skipper" },
4244
{ name: "skjs", slashworkId: "g_d_Px84GPeIF977BNqP0fGn", authToken: "skjs" },
4345
{ name: "skip", slashworkId: "g_cQCWnkXg9OvL08OvMC6XKZ", authToken: "skip" },
4446
];
4547

48+
const seededGroups = new Set<string>();
4649
for (const g of groups) {
50+
// Check if token exists in DB (may have been seeded in a previous run)
51+
const tokenExists = seededTokens.has(g.authToken) ||
52+
(await pool.query("SELECT 1 FROM auth_tokens WHERE name = $1", [g.authToken])).rows.length > 0;
53+
if (!tokenExists) {
54+
console.warn(` Skipping group "${g.name}": auth_token "${g.authToken}" not available`);
55+
continue;
56+
}
4757
await pool.query(
4858
`INSERT INTO groups (name, slashwork_id, auth_token) VALUES ($1, $2, $3)
4959
ON CONFLICT (name) DO UPDATE SET slashwork_id = EXCLUDED.slashwork_id, auth_token = EXCLUDED.auth_token`,
5060
[g.name, g.slashworkId, g.authToken],
5161
);
62+
seededGroups.add(g.name);
5263
console.log(` group "${g.name}" seeded`);
5364
}
5465

55-
// Seed routes
66+
// Seed routes (skip if dependencies aren't available)
5667
const routes: Array<
5768
| { name: string; groupName: string; streamId?: undefined; authToken?: undefined }
5869
| { name: string; groupName?: undefined; streamId: string; authToken: string }
@@ -65,6 +76,22 @@ async function seed() {
6576
];
6677

6778
for (const r of routes) {
79+
if (r.groupName) {
80+
const groupExists = seededGroups.has(r.groupName) ||
81+
(await pool.query("SELECT 1 FROM groups WHERE name = $1", [r.groupName])).rows.length > 0;
82+
if (!groupExists) {
83+
console.warn(` Skipping route "${r.name}": group "${r.groupName}" not available`);
84+
continue;
85+
}
86+
}
87+
if (r.authToken) {
88+
const tokenExists = seededTokens.has(r.authToken) ||
89+
(await pool.query("SELECT 1 FROM auth_tokens WHERE name = $1", [r.authToken])).rows.length > 0;
90+
if (!tokenExists) {
91+
console.warn(` Skipping route "${r.name}": auth_token "${r.authToken}" not available`);
92+
continue;
93+
}
94+
}
6895
await pool.query(
6996
`INSERT INTO routes (name, group_name, stream_id, auth_token) VALUES ($1, $2, $3, $4)
7097
ON CONFLICT (name) DO UPDATE SET group_name = EXCLUDED.group_name, stream_id = EXCLUDED.stream_id, auth_token = EXCLUDED.auth_token`,

src/config.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,25 +35,8 @@ export function loadAppConfig(): AppConfig {
3535
throw new Error("config: SLASHWORK_GRAPHQL_URL is required");
3636
}
3737

38-
const config: AppConfig = {
38+
return {
3939
github: { webhookSecret },
4040
slashwork: { graphqlUrl },
4141
};
42-
43-
if (process.env.GOOGLE_SERVICE_ACCOUNT_KEY) {
44-
const authToken = process.env.SLASHWORK_AUTH_TOKEN_GOOGLE_CALENDAR;
45-
if (!authToken) {
46-
throw new Error("config: SLASHWORK_AUTH_TOKEN_GOOGLE_CALENDAR is required when GOOGLE_SERVICE_ACCOUNT_KEY is set");
47-
}
48-
49-
config.calendar = {
50-
serviceAccountKey: process.env.GOOGLE_SERVICE_ACCOUNT_KEY,
51-
authToken,
52-
users: [
53-
{ name: "Hugo", calendarId: "hugo@skiplabs.io", targetId: "g_cR_HOoSUCphBLt7gktCEyi" },
54-
],
55-
};
56-
}
57-
58-
return config;
5942
}

src/db.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2+
import pg from "pg";
3+
import {
4+
getCalendarUsers,
5+
getAuthToken,
6+
getAuthTokens,
7+
getGroups,
8+
getRoutes,
9+
getAllRoutes,
10+
resolveRouteFromDb,
11+
} from "./db";
12+
13+
// These tests require POSTGRESQL_ADDON_URI to be set and the DB to be migrated+seeded.
14+
// They read existing data — no writes, no cleanup needed.
15+
16+
const hasDb = !!process.env.POSTGRESQL_ADDON_URI;
17+
18+
describe.skipIf(!hasDb)("db queries", () => {
19+
test("getAuthTokens returns entries with masked tokens", async () => {
20+
const tokens = await getAuthTokens();
21+
expect(tokens.length).toBeGreaterThan(0);
22+
for (const t of tokens) {
23+
expect(t.name).toBeTruthy();
24+
expect(t.tokenPreview).toMatch(/^.{8}\.\.\..{4}$/);
25+
}
26+
});
27+
28+
test("getAuthToken returns token for known name", async () => {
29+
const token = await getAuthToken("skipper");
30+
expect(token).toBeTruthy();
31+
expect(typeof token).toBe("string");
32+
});
33+
34+
test("getAuthToken returns null for unknown name", async () => {
35+
const token = await getAuthToken("nonexistent_token_xyz");
36+
expect(token).toBeNull();
37+
});
38+
39+
test("getGroups returns entries with required fields", async () => {
40+
const groups = await getGroups();
41+
expect(groups.length).toBeGreaterThan(0);
42+
for (const g of groups) {
43+
expect(g.name).toBeTruthy();
44+
expect(g.slashworkId).toBeTruthy();
45+
expect(g.authToken).toBeTruthy();
46+
}
47+
});
48+
49+
test("getRoutes returns entries with correct shape", async () => {
50+
const routes = await getRoutes();
51+
expect(routes.length).toBeGreaterThan(0);
52+
for (const r of routes) {
53+
expect(r.name).toBeTruthy();
54+
// Each route is either group-based or stream-based
55+
if (r.groupName) {
56+
expect(r.streamId).toBeNull();
57+
expect(r.authToken).toBeNull();
58+
} else {
59+
expect(r.streamId).toBeTruthy();
60+
expect(r.authToken).toBeTruthy();
61+
}
62+
}
63+
});
64+
65+
test("getAllRoutes resolves target IDs and auth tokens", async () => {
66+
const routes = await getAllRoutes();
67+
expect(routes.length).toBeGreaterThan(0);
68+
for (const r of routes) {
69+
expect(r.name).toBeTruthy();
70+
expect(r.targetId).toBeTruthy();
71+
expect(r.authToken).toBeTruthy();
72+
}
73+
});
74+
75+
test("resolveRouteFromDb returns resolved route for known name", async () => {
76+
const route = await resolveRouteFromDb("skipper");
77+
expect(route).not.toBeNull();
78+
expect(route!.targetId).toBeTruthy();
79+
expect(route!.authToken).toBeTruthy();
80+
});
81+
82+
test("resolveRouteFromDb returns null for unknown route", async () => {
83+
const route = await resolveRouteFromDb("nonexistent_route_xyz");
84+
expect(route).toBeNull();
85+
});
86+
87+
test("getCalendarUsers returns entries with required fields", async () => {
88+
const users = await getCalendarUsers();
89+
expect(users.length).toBeGreaterThan(0);
90+
for (const u of users) {
91+
expect(u.name).toBeTruthy();
92+
expect(u.calendarId).toBeTruthy();
93+
expect(u.targetId).toBeTruthy();
94+
}
95+
});
96+
97+
test("getAuthToken returns calendar token", async () => {
98+
const token = await getAuthToken("google_calendar");
99+
expect(token).toBeTruthy();
100+
});
101+
});

0 commit comments

Comments
 (0)