mirror of
https://github.com/kennethnym/aris.git
synced 2026-06-17 04:51:18 +01:00
feat: add reminder source (#126)
This commit is contained in:
@@ -60,3 +60,66 @@ export const userSources = pgTable(
|
||||
index("user_sources_user_id_enabled_idx").on(t.userId, t.enabled),
|
||||
],
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FREYA — reminders source storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const reminders = pgTable(
|
||||
"reminders",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
title: text("title").notNull(),
|
||||
notes: text("notes"),
|
||||
dueAt: timestamp("due_at").notNull(),
|
||||
timeZone: text("time_zone").notNull().default("UTC"),
|
||||
recurrence: jsonb("recurrence"),
|
||||
priority: text("priority").notNull().default("normal"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
index("reminders_user_id_due_at_idx").on(t.userId, t.dueAt),
|
||||
index("reminders_user_id_updated_at_idx").on(t.userId, t.updatedAt),
|
||||
],
|
||||
)
|
||||
|
||||
export const reminderOccurrenceOverrides = pgTable(
|
||||
"reminder_occurrence_overrides",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
reminderId: uuid("reminder_id")
|
||||
.notNull()
|
||||
.references(() => reminders.id, { onDelete: "cascade" }),
|
||||
occurrenceId: text("occurrence_id").notNull(),
|
||||
originalDueAt: timestamp("original_due_at").notNull(),
|
||||
patch: jsonb("patch"),
|
||||
completedAt: timestamp("completed_at"),
|
||||
deletedAt: timestamp("deleted_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at")
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date()),
|
||||
},
|
||||
(t) => [
|
||||
unique("reminder_occurrence_overrides_reminder_id_occurrence_id_unique").on(
|
||||
t.reminderId,
|
||||
t.occurrenceId,
|
||||
),
|
||||
index("reminder_occurrence_overrides_user_id_reminder_id_idx").on(t.userId, t.reminderId),
|
||||
index("reminder_occurrence_overrides_user_id_original_due_at_idx").on(
|
||||
t.userId,
|
||||
t.originalDueAt,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
50
apps/freya-backend/src/reminders/provider.test.ts
Normal file
50
apps/freya-backend/src/reminders/provider.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import type { Database } from "../db/index.ts"
|
||||
|
||||
import { ReminderSourceProvider } from "./provider.ts"
|
||||
|
||||
const fakeDb = {} as Database
|
||||
|
||||
describe("ReminderSourceProvider", () => {
|
||||
const provider = new ReminderSourceProvider({ db: fakeDb })
|
||||
|
||||
test("sourceId is freya.reminders", () => {
|
||||
expect(provider.sourceId).toBe("freya.reminders")
|
||||
})
|
||||
|
||||
test("throws when config has extra keys", async () => {
|
||||
await expect(
|
||||
provider.feedSourceForUser("user-1", { lookAheadMs: 1000, extra: true }, null),
|
||||
).rejects.toThrow("Invalid reminders config")
|
||||
})
|
||||
|
||||
test("throws when defaultTimeZone is invalid", async () => {
|
||||
await expect(
|
||||
provider.feedSourceForUser("user-1", { defaultTimeZone: "Not/AZone" }, null),
|
||||
).rejects.toThrow("Invalid reminders config")
|
||||
})
|
||||
|
||||
test("returns ReminderSource with valid config", async () => {
|
||||
const source = await provider.feedSourceForUser(
|
||||
"user-1",
|
||||
{
|
||||
lookAheadMs: 48 * 60 * 60 * 1000,
|
||||
lookBackMs: 60 * 60 * 1000,
|
||||
includeCompleted: true,
|
||||
defaultTimeZone: "Europe/London",
|
||||
},
|
||||
null,
|
||||
)
|
||||
|
||||
expect(source).toBeDefined()
|
||||
expect(source.id).toBe("freya.reminders")
|
||||
})
|
||||
|
||||
test("returns ReminderSource with empty config", async () => {
|
||||
const source = await provider.feedSourceForUser("user-1", {}, null)
|
||||
|
||||
expect(source).toBeDefined()
|
||||
expect(source.id).toBe("freya.reminders")
|
||||
})
|
||||
})
|
||||
48
apps/freya-backend/src/reminders/provider.ts
Normal file
48
apps/freya-backend/src/reminders/provider.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { ReminderSource, ReminderTimeZoneInput } from "@freya/source-reminders"
|
||||
import { type } from "arktype"
|
||||
|
||||
import type { Database } from "../db/index.ts"
|
||||
import type { FeedSourceProvider } from "../session/feed-source-provider.ts"
|
||||
|
||||
import { DrizzleReminderStorage } from "./storage.ts"
|
||||
|
||||
export interface ReminderSourceProviderOptions {
|
||||
db: Database
|
||||
}
|
||||
|
||||
export const reminderConfig = type({
|
||||
"+": "reject",
|
||||
"lookAheadMs?": "number.integer >= 0",
|
||||
"lookBackMs?": "number.integer >= 0",
|
||||
"includeCompleted?": "boolean",
|
||||
"defaultTimeZone?": ReminderTimeZoneInput,
|
||||
})
|
||||
|
||||
export class ReminderSourceProvider implements FeedSourceProvider {
|
||||
readonly sourceId = "freya.reminders"
|
||||
readonly configSchema = reminderConfig
|
||||
private readonly db: Database
|
||||
|
||||
constructor(options: ReminderSourceProviderOptions) {
|
||||
this.db = options.db
|
||||
}
|
||||
|
||||
async feedSourceForUser(
|
||||
userId: string,
|
||||
config: unknown,
|
||||
_credentials: unknown,
|
||||
): Promise<ReminderSource> {
|
||||
const parsed = reminderConfig(config)
|
||||
if (parsed instanceof type.errors) {
|
||||
throw new Error(`Invalid reminders config: ${parsed.summary}`)
|
||||
}
|
||||
|
||||
return new ReminderSource({
|
||||
storage: new DrizzleReminderStorage(this.db, userId),
|
||||
lookAheadMs: parsed.lookAheadMs,
|
||||
lookBackMs: parsed.lookBackMs,
|
||||
includeCompleted: parsed.includeCompleted,
|
||||
defaultTimeZone: parsed.defaultTimeZone,
|
||||
})
|
||||
}
|
||||
}
|
||||
276
apps/freya-backend/src/reminders/storage.ts
Normal file
276
apps/freya-backend/src/reminders/storage.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import type {
|
||||
CreateReminderInput,
|
||||
Reminder,
|
||||
ReminderListParams,
|
||||
ReminderOccurrenceOverride,
|
||||
ReminderOccurrenceOverrideInput,
|
||||
ReminderOccurrenceOverrideListParams,
|
||||
ReminderOccurrencePatch,
|
||||
ReminderPatch,
|
||||
ReminderPriority,
|
||||
ReminderRecurrence,
|
||||
ReminderStorage,
|
||||
} from "@freya/source-reminders"
|
||||
|
||||
import {
|
||||
ReminderOccurrencePatchInput,
|
||||
ReminderPriority as ReminderPriorityValue,
|
||||
ReminderPriorityInput,
|
||||
ReminderRecurrenceInput,
|
||||
} from "@freya/source-reminders"
|
||||
import { type } from "arktype"
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
|
||||
import type { Database } from "../db/index.ts"
|
||||
|
||||
import { reminderOccurrenceOverrides, reminders } from "../db/schema.ts"
|
||||
|
||||
interface ArkSchema<T> {
|
||||
(value: unknown): T | InstanceType<typeof type.errors>
|
||||
}
|
||||
|
||||
type ReminderRow = typeof reminders.$inferSelect
|
||||
type ReminderInsert = typeof reminders.$inferInsert
|
||||
type ReminderOccurrenceOverrideRow = typeof reminderOccurrenceOverrides.$inferSelect
|
||||
type ReminderOccurrenceOverrideInsert = typeof reminderOccurrenceOverrides.$inferInsert
|
||||
|
||||
export class DrizzleReminderStorage implements ReminderStorage {
|
||||
private readonly db: Database
|
||||
private readonly userId: string
|
||||
|
||||
constructor(db: Database, userId: string) {
|
||||
this.db = db
|
||||
this.userId = userId
|
||||
}
|
||||
|
||||
async listReminders(_params: ReminderListParams): Promise<Reminder[]> {
|
||||
const rows = await this.db.select().from(reminders).where(eq(reminders.userId, this.userId))
|
||||
|
||||
return rows.map(rowToReminder)
|
||||
}
|
||||
|
||||
async getReminder(id: string): Promise<Reminder | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(reminders)
|
||||
.where(and(eq(reminders.userId, this.userId), eq(reminders.id, id)))
|
||||
.limit(1)
|
||||
|
||||
return rows[0] ? rowToReminder(rows[0]) : null
|
||||
}
|
||||
|
||||
async createReminder(input: CreateReminderInput): Promise<Reminder> {
|
||||
const rows = await this.db
|
||||
.insert(reminders)
|
||||
.values({
|
||||
userId: this.userId,
|
||||
title: input.title,
|
||||
notes: input.notes ?? null,
|
||||
dueAt: input.dueAt,
|
||||
timeZone: input.timeZone ?? "UTC",
|
||||
recurrence: serializeRecurrence(input.recurrence ?? null),
|
||||
priority: input.priority ?? ReminderPriorityValue.Normal,
|
||||
})
|
||||
.returning()
|
||||
|
||||
return rowToReminder(requireRow(rows))
|
||||
}
|
||||
|
||||
async updateReminder(id: string, patch: ReminderPatch): Promise<Reminder> {
|
||||
const update: Partial<ReminderInsert> = { updatedAt: new Date() }
|
||||
|
||||
if (hasOwn(patch, "title")) update.title = patch.title
|
||||
if (hasOwn(patch, "notes")) update.notes = patch.notes ?? null
|
||||
if (hasOwn(patch, "dueAt")) update.dueAt = patch.dueAt
|
||||
if (hasOwn(patch, "timeZone")) update.timeZone = patch.timeZone
|
||||
if (hasOwn(patch, "recurrence")) update.recurrence = serializeRecurrence(patch.recurrence)
|
||||
if (hasOwn(patch, "priority")) update.priority = patch.priority
|
||||
|
||||
const rows = await this.db
|
||||
.update(reminders)
|
||||
.set(update)
|
||||
.where(and(eq(reminders.userId, this.userId), eq(reminders.id, id)))
|
||||
.returning()
|
||||
|
||||
return rowToReminder(requireRow(rows, `Reminder not found: ${id}`))
|
||||
}
|
||||
|
||||
async deleteReminder(id: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(reminders)
|
||||
.where(and(eq(reminders.userId, this.userId), eq(reminders.id, id)))
|
||||
}
|
||||
|
||||
async listOccurrenceOverrides(
|
||||
params: ReminderOccurrenceOverrideListParams,
|
||||
): Promise<ReminderOccurrenceOverride[]> {
|
||||
if (params.reminderIds.length === 0) return []
|
||||
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(reminderOccurrenceOverrides)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderOccurrenceOverrides.userId, this.userId),
|
||||
inArray(reminderOccurrenceOverrides.reminderId, [...params.reminderIds]),
|
||||
),
|
||||
)
|
||||
|
||||
return rows.map(rowToOccurrenceOverride)
|
||||
}
|
||||
|
||||
async getOccurrenceOverride(
|
||||
reminderId: string,
|
||||
occurrenceId: string,
|
||||
): Promise<ReminderOccurrenceOverride | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(reminderOccurrenceOverrides)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderOccurrenceOverrides.userId, this.userId),
|
||||
eq(reminderOccurrenceOverrides.reminderId, reminderId),
|
||||
eq(reminderOccurrenceOverrides.occurrenceId, occurrenceId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return rows[0] ? rowToOccurrenceOverride(rows[0]) : null
|
||||
}
|
||||
|
||||
async upsertOccurrenceOverride(
|
||||
input: ReminderOccurrenceOverrideInput,
|
||||
): Promise<ReminderOccurrenceOverride> {
|
||||
const values: ReminderOccurrenceOverrideInsert = {
|
||||
userId: this.userId,
|
||||
reminderId: input.reminderId,
|
||||
occurrenceId: input.occurrenceId,
|
||||
originalDueAt: input.originalDueAt,
|
||||
patch: serializeOccurrencePatch(input.patch),
|
||||
completedAt: input.completedAt ?? null,
|
||||
deletedAt: input.deletedAt ?? null,
|
||||
}
|
||||
|
||||
const rows = await this.db
|
||||
.insert(reminderOccurrenceOverrides)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [reminderOccurrenceOverrides.reminderId, reminderOccurrenceOverrides.occurrenceId],
|
||||
set: {
|
||||
originalDueAt: values.originalDueAt,
|
||||
patch: values.patch,
|
||||
completedAt: values.completedAt,
|
||||
deletedAt: values.deletedAt,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
|
||||
return rowToOccurrenceOverride(requireRow(rows))
|
||||
}
|
||||
|
||||
async deleteOccurrenceOverride(reminderId: string, occurrenceId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(reminderOccurrenceOverrides)
|
||||
.where(
|
||||
and(
|
||||
eq(reminderOccurrenceOverrides.userId, this.userId),
|
||||
eq(reminderOccurrenceOverrides.reminderId, reminderId),
|
||||
eq(reminderOccurrenceOverrides.occurrenceId, occurrenceId),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function rowToReminder(row: ReminderRow): Reminder {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
notes: row.notes,
|
||||
dueAt: row.dueAt,
|
||||
timeZone: row.timeZone,
|
||||
recurrence: parseRecurrence(row.recurrence),
|
||||
priority: assertSchema<ReminderPriority>(ReminderPriorityInput, row.priority),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function rowToOccurrenceOverride(row: ReminderOccurrenceOverrideRow): ReminderOccurrenceOverride {
|
||||
return {
|
||||
reminderId: row.reminderId,
|
||||
occurrenceId: row.occurrenceId,
|
||||
originalDueAt: row.originalDueAt,
|
||||
patch: parseOccurrencePatch(row.patch),
|
||||
completedAt: row.completedAt,
|
||||
deletedAt: row.deletedAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function parseRecurrence(value: unknown): ReminderRecurrence | null {
|
||||
if (value === null || value === undefined) return null
|
||||
return assertSchema<ReminderRecurrence>(ReminderRecurrenceInput, value)
|
||||
}
|
||||
|
||||
function parseOccurrencePatch(value: unknown): ReminderOccurrencePatch | undefined {
|
||||
if (value === null || value === undefined) return undefined
|
||||
return assertSchema<ReminderOccurrencePatch>(ReminderOccurrencePatchInput, value)
|
||||
}
|
||||
|
||||
function serializeRecurrence(recurrence: ReminderRecurrence | null | undefined): unknown {
|
||||
if (!recurrence) return null
|
||||
|
||||
const value: Record<string, unknown> = {
|
||||
frequency: recurrence.frequency,
|
||||
interval: recurrence.interval,
|
||||
}
|
||||
|
||||
if (recurrence.weekdays !== undefined) value.weekdays = recurrence.weekdays
|
||||
if (recurrence.count !== undefined) value.count = recurrence.count
|
||||
if (recurrence.until !== undefined) value.until = recurrence.until.toISOString()
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function serializeOccurrencePatch(patch: ReminderOccurrencePatch | undefined): unknown {
|
||||
if (!patch) return null
|
||||
|
||||
const value: Record<string, unknown> = {}
|
||||
if (hasOwn(patch, "title")) value.title = patch.title
|
||||
if (hasOwn(patch, "notes")) value.notes = patch.notes
|
||||
if (hasOwn(patch, "dueAt") && patch.dueAt !== undefined) {
|
||||
value.dueAt = patch.dueAt.toISOString()
|
||||
}
|
||||
if (hasOwn(patch, "timeZone")) value.timeZone = patch.timeZone
|
||||
if (hasOwn(patch, "priority")) value.priority = patch.priority
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function requireRow<TRow>(
|
||||
rows: TRow[],
|
||||
message = "Reminder storage mutation returned no rows",
|
||||
): TRow {
|
||||
const row = rows[0]
|
||||
if (!row) {
|
||||
throw new Error(message)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function assertSchema<T>(schema: ArkSchema<T>, value: unknown): T {
|
||||
const result = schema(value)
|
||||
if (result instanceof type.errors) {
|
||||
throw new Error(result.summary)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function hasOwn<TObject extends object, TKey extends PropertyKey>(
|
||||
object: TObject,
|
||||
key: TKey,
|
||||
): object is TObject & Record<TKey, unknown> {
|
||||
return Object.prototype.hasOwnProperty.call(object, key)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { GoogleMapsSourceProvider } from "./google-maps/provider.ts"
|
||||
import { CredentialEncryptor } from "./lib/crypto.ts"
|
||||
import { registerLocationHttpHandlers } from "./location/http.ts"
|
||||
import { LocationSourceProvider } from "./location/provider.ts"
|
||||
import { ReminderSourceProvider } from "./reminders/provider.ts"
|
||||
import { UserSessionManager } from "./session/index.ts"
|
||||
import { registerSourcesHttpHandlers } from "./sources/http.ts"
|
||||
import { TflSourceProvider } from "./tfl/provider.ts"
|
||||
@@ -58,6 +59,7 @@ function main() {
|
||||
providers: [
|
||||
new CalDavSourceProvider(),
|
||||
new LocationSourceProvider(),
|
||||
new ReminderSourceProvider({ db }),
|
||||
new WeatherSourceProvider({
|
||||
credentials: {
|
||||
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
|
||||
|
||||
Reference in New Issue
Block a user