mirror of
https://github.com/kennethnym/aris.git
synced 2026-06-15 12:01:18 +01:00
Compare commits
1 Commits
fix/requir
...
feat/defau
| Author | SHA1 | Date | |
|---|---|---|---|
|
8532d6dbac
|
83
apps/freya-backend/src/auth/index.test.ts
Normal file
83
apps/freya-backend/src/auth/index.test.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import type { Database } from "../db/index.ts"
|
||||||
|
|
||||||
|
import { DEFAULT_ENABLED_SOURCE_IDS } from "../sources/default-sources.ts"
|
||||||
|
import { createAuth } from "./index.ts"
|
||||||
|
|
||||||
|
interface UserSourceInsertRow {
|
||||||
|
sourceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecordingDb {
|
||||||
|
db: Database
|
||||||
|
rows: () => UserSourceInsertRow[] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalBetterAuthSecret = process.env.BETTER_AUTH_SECRET
|
||||||
|
|
||||||
|
function createRecordingDb(): RecordingDb {
|
||||||
|
let insertedRows: UserSourceInsertRow[] | undefined
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
insert() {
|
||||||
|
return {
|
||||||
|
values(rows: UserSourceInsertRow[]) {
|
||||||
|
insertedRows = rows
|
||||||
|
|
||||||
|
return {
|
||||||
|
async onConflictDoNothing() {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as unknown as Database
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
rows: () => insertedRows,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalBetterAuthSecret === undefined) {
|
||||||
|
delete process.env.BETTER_AUTH_SECRET
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.BETTER_AUTH_SECRET = originalBetterAuthSecret
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("createAuth", () => {
|
||||||
|
test("inserts default sources after Better Auth creates a user", async () => {
|
||||||
|
process.env.BETTER_AUTH_SECRET = "test-secret"
|
||||||
|
const recording = createRecordingDb()
|
||||||
|
const auth = createAuth(recording.db)
|
||||||
|
const afterCreateUser = auth.options.databaseHooks?.user?.create?.after
|
||||||
|
|
||||||
|
if (!afterCreateUser) {
|
||||||
|
throw new Error("Expected a user create after hook")
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
await afterCreateUser(
|
||||||
|
{
|
||||||
|
id: "user-1",
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
emailVerified: false,
|
||||||
|
image: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const rows = recording.rows()
|
||||||
|
if (!rows) {
|
||||||
|
throw new Error("Expected the auth hook to insert default sources")
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(rows.map((row) => row.sourceId)).toEqual([...DEFAULT_ENABLED_SOURCE_IDS])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -5,6 +5,7 @@ import { admin } from "better-auth/plugins"
|
|||||||
import type { Database } from "../db/index.ts"
|
import type { Database } from "../db/index.ts"
|
||||||
|
|
||||||
import * as schema from "../db/schema.ts"
|
import * as schema from "../db/schema.ts"
|
||||||
|
import { insertDefaultUserSources } from "../sources/default-sources.ts"
|
||||||
|
|
||||||
export function createAuth(db: Database) {
|
export function createAuth(db: Database) {
|
||||||
if (!process.env.BETTER_AUTH_SECRET) {
|
if (!process.env.BETTER_AUTH_SECRET) {
|
||||||
@@ -22,6 +23,15 @@ export function createAuth(db: Database) {
|
|||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
databaseHooks: {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
async after(user, _context) {
|
||||||
|
await insertDefaultUserSources(db, user.id)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [admin()],
|
plugins: [admin()],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe("GoogleMapsSourceProvider", () => {
|
|||||||
|
|
||||||
test("throws when service API key is empty", () => {
|
test("throws when service API key is empty", () => {
|
||||||
expect(() => new GoogleMapsSourceProvider({ apiKey: "" })).toThrow(
|
expect(() => new GoogleMapsSourceProvider({ apiKey: "" })).toThrow(
|
||||||
"Google Maps API key must be configured",
|
"Google Maps MCP API key must be configured",
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export class GoogleMapsSourceProvider implements FeedSourceProvider {
|
|||||||
|
|
||||||
constructor(options: GoogleMapsSourceProviderOptions) {
|
constructor(options: GoogleMapsSourceProviderOptions) {
|
||||||
if (!nonEmptyString(options.apiKey)) {
|
if (!nonEmptyString(options.apiKey)) {
|
||||||
throw new Error("Google Maps API key must be configured")
|
throw new Error("Google Maps MCP API key must be configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
this.apiKey = options.apiKey
|
this.apiKey = options.apiKey
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
|
|
||||||
import { ensureEnv } from "./env.ts"
|
|
||||||
|
|
||||||
describe("ensureEnv", () => {
|
|
||||||
test("returns trimmed required env values", () => {
|
|
||||||
const env = ensureEnv({
|
|
||||||
BETTER_AUTH_SECRET: " auth-secret ",
|
|
||||||
CREDENTIAL_ENCRYPTION_KEY: " credential-key ",
|
|
||||||
DATABASE_URL: " postgres://example ",
|
|
||||||
EXA_API_KEY: " exa-key ",
|
|
||||||
GOOGLE_MAPS_API_KEY: " google-maps-key ",
|
|
||||||
OPENROUTER_API_KEY: " openrouter-key ",
|
|
||||||
OPENROUTER_MODEL: " model-name ",
|
|
||||||
TFL_API_KEY: " tfl-key ",
|
|
||||||
WEATHERKIT_KEY_ID: " weather-key-id ",
|
|
||||||
WEATHERKIT_PRIVATE_KEY: " weather-private-key ",
|
|
||||||
WEATHERKIT_SERVICE_ID: " weather-service-id ",
|
|
||||||
WEATHERKIT_TEAM_ID: " weather-team-id ",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(env).toEqual({
|
|
||||||
betterAuthSecret: "auth-secret",
|
|
||||||
credentialEncryptionKey: "credential-key",
|
|
||||||
databaseUrl: "postgres://example",
|
|
||||||
exaApiKey: "exa-key",
|
|
||||||
googleMapsApiKey: "google-maps-key",
|
|
||||||
openrouterApiKey: "openrouter-key",
|
|
||||||
openrouterModel: "model-name",
|
|
||||||
tflApiKey: "tfl-key",
|
|
||||||
weatherkitKeyId: "weather-key-id",
|
|
||||||
weatherkitPrivateKey: "weather-private-key",
|
|
||||||
weatherkitServiceId: "weather-service-id",
|
|
||||||
weatherkitTeamId: "weather-team-id",
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("does not allow the old Google Maps MCP fallback key", () => {
|
|
||||||
expect(() =>
|
|
||||||
ensureEnv({
|
|
||||||
BETTER_AUTH_SECRET: "auth-secret",
|
|
||||||
CREDENTIAL_ENCRYPTION_KEY: "credential-key",
|
|
||||||
DATABASE_URL: "postgres://example",
|
|
||||||
EXA_API_KEY: "exa-key",
|
|
||||||
GOOGLE_MAPS_MCP_API_KEY: "google-maps-mcp-key",
|
|
||||||
OPENROUTER_API_KEY: "openrouter-key",
|
|
||||||
TFL_API_KEY: "tfl-key",
|
|
||||||
WEATHERKIT_KEY_ID: "weather-key-id",
|
|
||||||
WEATHERKIT_PRIVATE_KEY: "weather-private-key",
|
|
||||||
WEATHERKIT_SERVICE_ID: "weather-service-id",
|
|
||||||
WEATHERKIT_TEAM_ID: "weather-team-id",
|
|
||||||
}),
|
|
||||||
).toThrow("Missing required environment variables: GOOGLE_MAPS_API_KEY")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("allows openrouter model to be omitted", () => {
|
|
||||||
const env = ensureEnv({
|
|
||||||
BETTER_AUTH_SECRET: "auth-secret",
|
|
||||||
CREDENTIAL_ENCRYPTION_KEY: "credential-key",
|
|
||||||
DATABASE_URL: "postgres://example",
|
|
||||||
EXA_API_KEY: "exa-key",
|
|
||||||
GOOGLE_MAPS_API_KEY: "google-maps-key",
|
|
||||||
OPENROUTER_API_KEY: "openrouter-key",
|
|
||||||
TFL_API_KEY: "tfl-key",
|
|
||||||
WEATHERKIT_KEY_ID: "weather-key-id",
|
|
||||||
WEATHERKIT_PRIVATE_KEY: "weather-private-key",
|
|
||||||
WEATHERKIT_SERVICE_ID: "weather-service-id",
|
|
||||||
WEATHERKIT_TEAM_ID: "weather-team-id",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(env.googleMapsApiKey).toBe("google-maps-key")
|
|
||||||
expect(env.openrouterModel).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws with all missing required env names", () => {
|
|
||||||
expect(() => ensureEnv({})).toThrow(
|
|
||||||
"Missing required environment variables: BETTER_AUTH_SECRET, CREDENTIAL_ENCRYPTION_KEY, DATABASE_URL, EXA_API_KEY, OPENROUTER_API_KEY, TFL_API_KEY, WEATHERKIT_PRIVATE_KEY, WEATHERKIT_KEY_ID, WEATHERKIT_TEAM_ID, WEATHERKIT_SERVICE_ID, GOOGLE_MAPS_API_KEY",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("treats whitespace-only values as missing", () => {
|
|
||||||
expect(() =>
|
|
||||||
ensureEnv({
|
|
||||||
BETTER_AUTH_SECRET: "auth-secret",
|
|
||||||
CREDENTIAL_ENCRYPTION_KEY: "credential-key",
|
|
||||||
DATABASE_URL: "postgres://example",
|
|
||||||
EXA_API_KEY: " ",
|
|
||||||
GOOGLE_MAPS_API_KEY: "google-maps-key",
|
|
||||||
OPENROUTER_API_KEY: "openrouter-key",
|
|
||||||
TFL_API_KEY: "tfl-key",
|
|
||||||
WEATHERKIT_KEY_ID: "weather-key-id",
|
|
||||||
WEATHERKIT_PRIVATE_KEY: "weather-private-key",
|
|
||||||
WEATHERKIT_SERVICE_ID: "weather-service-id",
|
|
||||||
WEATHERKIT_TEAM_ID: "weather-team-id",
|
|
||||||
}),
|
|
||||||
).toThrow("Missing required environment variables: EXA_API_KEY")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
export interface ServerEnv {
|
|
||||||
betterAuthSecret: string
|
|
||||||
credentialEncryptionKey: string
|
|
||||||
databaseUrl: string
|
|
||||||
exaApiKey: string
|
|
||||||
googleMapsApiKey: string
|
|
||||||
openrouterApiKey: string
|
|
||||||
openrouterModel: string | undefined
|
|
||||||
tflApiKey: string
|
|
||||||
weatherkitKeyId: string
|
|
||||||
weatherkitPrivateKey: string
|
|
||||||
weatherkitServiceId: string
|
|
||||||
weatherkitTeamId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ensureEnv(env: Record<string, string | undefined>): ServerEnv {
|
|
||||||
const missing: string[] = []
|
|
||||||
|
|
||||||
const betterAuthSecret = readRequiredEnv(env, "BETTER_AUTH_SECRET", missing)
|
|
||||||
const credentialEncryptionKey = readRequiredEnv(env, "CREDENTIAL_ENCRYPTION_KEY", missing)
|
|
||||||
const databaseUrl = readRequiredEnv(env, "DATABASE_URL", missing)
|
|
||||||
const exaApiKey = readRequiredEnv(env, "EXA_API_KEY", missing)
|
|
||||||
const openrouterApiKey = readRequiredEnv(env, "OPENROUTER_API_KEY", missing)
|
|
||||||
const tflApiKey = readRequiredEnv(env, "TFL_API_KEY", missing)
|
|
||||||
const weatherkitPrivateKey = readRequiredEnv(env, "WEATHERKIT_PRIVATE_KEY", missing)
|
|
||||||
const weatherkitKeyId = readRequiredEnv(env, "WEATHERKIT_KEY_ID", missing)
|
|
||||||
const weatherkitTeamId = readRequiredEnv(env, "WEATHERKIT_TEAM_ID", missing)
|
|
||||||
const weatherkitServiceId = readRequiredEnv(env, "WEATHERKIT_SERVICE_ID", missing)
|
|
||||||
const googleMapsApiKey = readRequiredEnv(env, "GOOGLE_MAPS_API_KEY", missing)
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
|
||||||
throw new Error(`Missing required environment variables: ${missing.join(", ")}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
betterAuthSecret,
|
|
||||||
credentialEncryptionKey,
|
|
||||||
databaseUrl,
|
|
||||||
exaApiKey,
|
|
||||||
googleMapsApiKey,
|
|
||||||
openrouterApiKey,
|
|
||||||
openrouterModel: readOptionalEnv(env, "OPENROUTER_MODEL"),
|
|
||||||
tflApiKey,
|
|
||||||
weatherkitKeyId,
|
|
||||||
weatherkitPrivateKey,
|
|
||||||
weatherkitServiceId,
|
|
||||||
weatherkitTeamId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readRequiredEnv(
|
|
||||||
env: Record<string, string | undefined>,
|
|
||||||
name: string,
|
|
||||||
missing: string[],
|
|
||||||
): string {
|
|
||||||
const value = readOptionalEnv(env, name)
|
|
||||||
if (!value) {
|
|
||||||
missing.push(name)
|
|
||||||
}
|
|
||||||
return value ?? ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function readOptionalEnv(
|
|
||||||
env: Record<string, string | undefined>,
|
|
||||||
name: string,
|
|
||||||
): string | undefined {
|
|
||||||
const value = env[name]?.trim()
|
|
||||||
return value ? value : undefined
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ import { LocationSource } from "@freya/source-location"
|
|||||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||||
|
|
||||||
export class LocationSourceProvider implements FeedSourceProvider {
|
export class LocationSourceProvider implements FeedSourceProvider {
|
||||||
readonly sourceId = "freya.location"
|
readonly sourceId = LocationSource.id
|
||||||
|
|
||||||
async feedSourceForUser(
|
async feedSourceForUser(
|
||||||
_userId: string,
|
_userId: string,
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { createFeedEnhancer } from "./enhancement/enhance-feed.ts"
|
|||||||
import { createLlmClient } from "./enhancement/llm-client.ts"
|
import { createLlmClient } from "./enhancement/llm-client.ts"
|
||||||
import { GoogleMapsSourceProvider } from "./google-maps/provider.ts"
|
import { GoogleMapsSourceProvider } from "./google-maps/provider.ts"
|
||||||
import { CredentialEncryptor } from "./lib/crypto.ts"
|
import { CredentialEncryptor } from "./lib/crypto.ts"
|
||||||
import { ensureEnv } from "./lib/env.ts"
|
|
||||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||||
import { LocationSourceProvider } from "./location/provider.ts"
|
import { LocationSourceProvider } from "./location/provider.ts"
|
||||||
import { ReminderSourceProvider } from "./reminders/provider.ts"
|
import { ReminderSourceProvider } from "./reminders/provider.ts"
|
||||||
@@ -24,19 +23,36 @@ import { WeatherSourceProvider } from "./weather/provider.ts"
|
|||||||
import { WebSearchSourceProvider } from "./web-search/provider.ts"
|
import { WebSearchSourceProvider } from "./web-search/provider.ts"
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const env = ensureEnv(process.env)
|
const { db, close: closeDb } = createDatabase(process.env.DATABASE_URL!)
|
||||||
|
|
||||||
const { db, close: closeDb } = createDatabase(env.databaseUrl)
|
|
||||||
const auth = createAuth(db)
|
const auth = createAuth(db)
|
||||||
|
|
||||||
const feedEnhancer = createFeedEnhancer({
|
const openrouterApiKey = process.env.OPENROUTER_API_KEY
|
||||||
client: createLlmClient({
|
const feedEnhancer = openrouterApiKey
|
||||||
apiKey: env.openrouterApiKey,
|
? createFeedEnhancer({
|
||||||
model: env.openrouterModel,
|
client: createLlmClient({
|
||||||
}),
|
apiKey: openrouterApiKey,
|
||||||
})
|
model: process.env.OPENROUTER_MODEL || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
if (!feedEnhancer) {
|
||||||
|
console.warn("[enhancement] OPENROUTER_API_KEY not set — feed enhancement disabled")
|
||||||
|
}
|
||||||
|
|
||||||
const credentialEncryptor = new CredentialEncryptor(env.credentialEncryptionKey)
|
const credentialEncryptionKey = process.env.CREDENTIAL_ENCRYPTION_KEY
|
||||||
|
const credentialEncryptor = credentialEncryptionKey
|
||||||
|
? new CredentialEncryptor(credentialEncryptionKey)
|
||||||
|
: null
|
||||||
|
if (!credentialEncryptor) {
|
||||||
|
console.warn(
|
||||||
|
"[credentials] CREDENTIAL_ENCRYPTION_KEY not set — per-user credential storage disabled",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const googleMapsApiKey = process.env.GOOGLE_MAPS_API_KEY ?? process.env.GOOGLE_MAPS_MCP_API_KEY
|
||||||
|
if (!googleMapsApiKey) {
|
||||||
|
throw new Error("GOOGLE_MAPS_API_KEY or GOOGLE_MAPS_MCP_API_KEY must be set")
|
||||||
|
}
|
||||||
|
|
||||||
const sessionManager = new UserSessionManager({
|
const sessionManager = new UserSessionManager({
|
||||||
db,
|
db,
|
||||||
@@ -46,16 +62,16 @@ function main() {
|
|||||||
new ReminderSourceProvider({ db }),
|
new ReminderSourceProvider({ db }),
|
||||||
new WeatherSourceProvider({
|
new WeatherSourceProvider({
|
||||||
credentials: {
|
credentials: {
|
||||||
privateKey: env.weatherkitPrivateKey,
|
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||||
keyId: env.weatherkitKeyId,
|
keyId: process.env.WEATHERKIT_KEY_ID!,
|
||||||
teamId: env.weatherkitTeamId,
|
teamId: process.env.WEATHERKIT_TEAM_ID!,
|
||||||
serviceId: env.weatherkitServiceId,
|
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
new TflSourceProvider({ apiKey: env.tflApiKey }),
|
new TflSourceProvider({ apiKey: process.env.TFL_API_KEY! }),
|
||||||
new WebSearchSourceProvider({ apiKey: env.exaApiKey }),
|
new WebSearchSourceProvider({ apiKey: process.env.EXA_API_KEY }),
|
||||||
new GoogleMapsSourceProvider({
|
new GoogleMapsSourceProvider({
|
||||||
apiKey: env.googleMapsApiKey,
|
apiKey: googleMapsApiKey,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
feedEnhancer,
|
feedEnhancer,
|
||||||
|
|||||||
85
apps/freya-backend/src/sources/default-sources.test.ts
Normal file
85
apps/freya-backend/src/sources/default-sources.test.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { LocationSource } from "@freya/source-location"
|
||||||
|
import { WebSearchSource } from "@freya/source-web-search"
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
|
||||||
|
import type { Database } from "../db/index.ts"
|
||||||
|
|
||||||
|
import { userSources } from "../db/schema.ts"
|
||||||
|
import { DEFAULT_ENABLED_SOURCE_IDS, insertDefaultUserSources } from "./default-sources.ts"
|
||||||
|
|
||||||
|
interface UserSourceInsertRow {
|
||||||
|
userId: string
|
||||||
|
sourceId: string
|
||||||
|
enabled: boolean
|
||||||
|
config: unknown
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecordingDb {
|
||||||
|
db: Database
|
||||||
|
table: () => unknown
|
||||||
|
rows: () => UserSourceInsertRow[] | undefined
|
||||||
|
conflictTarget: () => readonly unknown[] | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRecordingDb(): RecordingDb {
|
||||||
|
let insertedTable: unknown
|
||||||
|
let insertedRows: UserSourceInsertRow[] | undefined
|
||||||
|
let target: readonly unknown[] | undefined
|
||||||
|
|
||||||
|
const db = {
|
||||||
|
insert(table: unknown) {
|
||||||
|
insertedTable = table
|
||||||
|
|
||||||
|
return {
|
||||||
|
values(rows: UserSourceInsertRow[]) {
|
||||||
|
insertedRows = rows
|
||||||
|
|
||||||
|
return {
|
||||||
|
async onConflictDoNothing(options: { target: readonly unknown[] }) {
|
||||||
|
target = options.target
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as unknown as Database
|
||||||
|
|
||||||
|
return {
|
||||||
|
db,
|
||||||
|
table: () => insertedTable,
|
||||||
|
rows: () => insertedRows,
|
||||||
|
conflictTarget: () => target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("default user sources", () => {
|
||||||
|
test("defines location and web search as default enabled sources", () => {
|
||||||
|
expect(DEFAULT_ENABLED_SOURCE_IDS).toEqual([LocationSource.id, WebSearchSource.id])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("inserts default enabled source rows for a user", async () => {
|
||||||
|
const recording = createRecordingDb()
|
||||||
|
|
||||||
|
await insertDefaultUserSources(recording.db, "user-1")
|
||||||
|
|
||||||
|
const rows = recording.rows()
|
||||||
|
if (!rows) {
|
||||||
|
throw new Error("Expected default source rows to be inserted")
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(recording.table()).toBe(userSources)
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
expect(rows.map((row) => row.sourceId)).toEqual([...DEFAULT_ENABLED_SOURCE_IDS])
|
||||||
|
expect(recording.conflictTarget()).toEqual([userSources.userId, userSources.sourceId])
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
expect(row.userId).toBe("user-1")
|
||||||
|
expect(row.enabled).toBe(true)
|
||||||
|
expect(row.config).toEqual({})
|
||||||
|
expect(row.createdAt).toBeInstanceOf(Date)
|
||||||
|
expect(row.updatedAt).toBe(row.createdAt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
30
apps/freya-backend/src/sources/default-sources.ts
Normal file
30
apps/freya-backend/src/sources/default-sources.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { LocationSource } from "@freya/source-location"
|
||||||
|
import { WebSearchSource } from "@freya/source-web-search"
|
||||||
|
|
||||||
|
import type { Database } from "../db/index.ts"
|
||||||
|
|
||||||
|
import { userSources } from "../db/schema.ts"
|
||||||
|
|
||||||
|
export const DEFAULT_ENABLED_SOURCE_IDS = [LocationSource.id, WebSearchSource.id] as const
|
||||||
|
|
||||||
|
export type DefaultEnabledSourceId = (typeof DEFAULT_ENABLED_SOURCE_IDS)[number]
|
||||||
|
|
||||||
|
export async function insertDefaultUserSources(db: Database, userId: string): Promise<void> {
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(userSources)
|
||||||
|
.values(
|
||||||
|
DEFAULT_ENABLED_SOURCE_IDS.map((sourceId) => ({
|
||||||
|
userId,
|
||||||
|
sourceId,
|
||||||
|
enabled: true,
|
||||||
|
config: {},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing({
|
||||||
|
target: [userSources.userId, userSources.sourceId],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ export type WebSearchSourceProviderOptions =
|
|||||||
| { apiKey?: never; client: WebSearchClient }
|
| { apiKey?: never; client: WebSearchClient }
|
||||||
|
|
||||||
export class WebSearchSourceProvider implements FeedSourceProvider {
|
export class WebSearchSourceProvider implements FeedSourceProvider {
|
||||||
readonly sourceId = "freya.web-search"
|
readonly sourceId = WebSearchSource.id
|
||||||
|
|
||||||
private readonly apiKey: string | undefined
|
private readonly apiKey: string | undefined
|
||||||
private readonly client: WebSearchClient | undefined
|
private readonly client: WebSearchClient | undefined
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ describe("LocationSource", () => {
|
|||||||
describe("FeedSource interface", () => {
|
describe("FeedSource interface", () => {
|
||||||
test("has correct id", () => {
|
test("has correct id", () => {
|
||||||
const source = new LocationSource()
|
const source = new LocationSource()
|
||||||
expect(source.id).toBe("freya.location")
|
expect(source.id).toBe(LocationSource.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("fetchItems always returns empty array", async () => {
|
test("fetchItems always returns empty array", async () => {
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import { type } from "arktype"
|
|||||||
|
|
||||||
import { Location, type LocationSourceOptions } from "./types.ts"
|
import { Location, type LocationSourceOptions } from "./types.ts"
|
||||||
|
|
||||||
export const LocationKey: ContextKey<Location> = contextKey("freya.location", "location")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A FeedSource that provides location context.
|
* A FeedSource that provides location context.
|
||||||
*
|
*
|
||||||
@@ -16,7 +14,9 @@ export const LocationKey: ContextKey<Location> = contextKey("freya.location", "l
|
|||||||
* Does not produce feed items - always returns empty array from `fetchItems`.
|
* Does not produce feed items - always returns empty array from `fetchItems`.
|
||||||
*/
|
*/
|
||||||
export class LocationSource implements FeedSource {
|
export class LocationSource implements FeedSource {
|
||||||
readonly id = "freya.location"
|
static readonly id = "freya.location"
|
||||||
|
|
||||||
|
readonly id = LocationSource.id
|
||||||
|
|
||||||
private readonly historySize: number
|
private readonly historySize: number
|
||||||
private locations: Location[] = []
|
private locations: Location[] = []
|
||||||
@@ -97,3 +97,5 @@ export class LocationSource implements FeedSource {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const LocationKey: ContextKey<Location> = contextKey(LocationSource.id, "location")
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ describe("WebSearchSource", () => {
|
|||||||
test("has correct id", () => {
|
test("has correct id", () => {
|
||||||
const source = new WebSearchSource({ client: new RecordingSearchClient() })
|
const source = new WebSearchSource({ client: new RecordingSearchClient() })
|
||||||
|
|
||||||
expect(source.id).toBe("freya.web-search")
|
expect(source.id).toBe(WebSearchSource.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not provide context or feed items", async () => {
|
test("does not provide context or feed items", async () => {
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ const SearchInput = type({
|
|||||||
* action and receive structured web results.
|
* action and receive structured web results.
|
||||||
*/
|
*/
|
||||||
export class WebSearchSource implements FeedSource {
|
export class WebSearchSource implements FeedSource {
|
||||||
readonly id = "freya.web-search"
|
static readonly id = "freya.web-search"
|
||||||
|
|
||||||
|
readonly id = WebSearchSource.id
|
||||||
|
|
||||||
private readonly client: WebSearchClient
|
private readonly client: WebSearchClient
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user