feat: add reminder source (#126)

This commit is contained in:
2026-06-14 00:05:19 +01:00
committed by GitHub
parent 38b21a1aa4
commit efd7537008
23 changed files with 4047 additions and 6 deletions

View File

@@ -49,6 +49,33 @@ CREATE TABLE "user_sources" (
CONSTRAINT "user_sources_user_id_source_id_unique" UNIQUE("user_id","source_id")
);
--> statement-breakpoint
CREATE TABLE "reminders" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"title" text NOT NULL,
"notes" text,
"due_at" timestamp NOT NULL,
"time_zone" text DEFAULT 'UTC' NOT NULL,
"recurrence" jsonb,
"priority" text DEFAULT 'normal' NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "reminder_occurrence_overrides" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" text NOT NULL,
"reminder_id" uuid NOT NULL,
"occurrence_id" text NOT NULL,
"original_due_at" timestamp NOT NULL,
"patch" jsonb,
"completed_at" timestamp,
"deleted_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "reminder_occurrence_overrides_reminder_id_occurrence_id_unique" UNIQUE("reminder_id","occurrence_id")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
@@ -61,6 +88,13 @@ CREATE TABLE "verification" (
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_sources" ADD CONSTRAINT "user_sources_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reminders" ADD CONSTRAINT "reminders_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reminder_occurrence_overrides" ADD CONSTRAINT "reminder_occurrence_overrides_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reminder_occurrence_overrides" ADD CONSTRAINT "reminder_occurrence_overrides_reminder_id_reminders_id_fk" FOREIGN KEY ("reminder_id") REFERENCES "public"."reminders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");
CREATE INDEX "reminders_user_id_due_at_idx" ON "reminders" USING btree ("user_id","due_at");--> statement-breakpoint
CREATE INDEX "reminders_user_id_updated_at_idx" ON "reminders" USING btree ("user_id","updated_at");--> statement-breakpoint
CREATE INDEX "reminder_occurrence_overrides_user_id_reminder_id_idx" ON "reminder_occurrence_overrides" USING btree ("user_id","reminder_id");--> statement-breakpoint
CREATE INDEX "reminder_occurrence_overrides_user_id_original_due_at_idx" ON "reminder_occurrence_overrides" USING btree ("user_id","original_due_at");--> statement-breakpoint
CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");

View File

@@ -441,6 +441,296 @@
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reminders": {
"name": "reminders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"due_at": {
"name": "due_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"time_zone": {
"name": "time_zone",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'UTC'"
},
"recurrence": {
"name": "recurrence",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'normal'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"reminders_user_id_due_at_idx": {
"name": "reminders_user_id_due_at_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "due_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"reminders_user_id_updated_at_idx": {
"name": "reminders_user_id_updated_at_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "updated_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"reminders_user_id_user_id_fk": {
"name": "reminders_user_id_user_id_fk",
"tableFrom": "reminders",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reminder_occurrence_overrides": {
"name": "reminder_occurrence_overrides",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reminder_id": {
"name": "reminder_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"occurrence_id": {
"name": "occurrence_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"original_due_at": {
"name": "original_due_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"patch": {
"name": "patch",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"reminder_occurrence_overrides_user_id_reminder_id_idx": {
"name": "reminder_occurrence_overrides_user_id_reminder_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "reminder_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"reminder_occurrence_overrides_user_id_original_due_at_idx": {
"name": "reminder_occurrence_overrides_user_id_original_due_at_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "original_due_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"reminder_occurrence_overrides_user_id_user_id_fk": {
"name": "reminder_occurrence_overrides_user_id_user_id_fk",
"tableFrom": "reminder_occurrence_overrides",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"reminder_occurrence_overrides_reminder_id_reminders_id_fk": {
"name": "reminder_occurrence_overrides_reminder_id_reminders_id_fk",
"tableFrom": "reminder_occurrence_overrides",
"tableTo": "reminders",
"columnsFrom": [
"reminder_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reminder_occurrence_overrides_reminder_id_occurrence_id_unique": {
"name": "reminder_occurrence_overrides_reminder_id_occurrence_id_unique",
"nullsNotDistinct": false,
"columns": [
"reminder_id",
"occurrence_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
@@ -454,4 +744,4 @@
"schemas": {},
"tables": {}
}
}
}

View File

@@ -463,6 +463,296 @@
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reminders": {
"name": "reminders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false
},
"due_at": {
"name": "due_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"time_zone": {
"name": "time_zone",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'UTC'"
},
"recurrence": {
"name": "recurrence",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"priority": {
"name": "priority",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'normal'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"reminders_user_id_due_at_idx": {
"name": "reminders_user_id_due_at_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "due_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"reminders_user_id_updated_at_idx": {
"name": "reminders_user_id_updated_at_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "updated_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"reminders_user_id_user_id_fk": {
"name": "reminders_user_id_user_id_fk",
"tableFrom": "reminders",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reminder_occurrence_overrides": {
"name": "reminder_occurrence_overrides",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"reminder_id": {
"name": "reminder_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"occurrence_id": {
"name": "occurrence_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"original_due_at": {
"name": "original_due_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"patch": {
"name": "patch",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"completed_at": {
"name": "completed_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"reminder_occurrence_overrides_user_id_reminder_id_idx": {
"name": "reminder_occurrence_overrides_user_id_reminder_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "reminder_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"reminder_occurrence_overrides_user_id_original_due_at_idx": {
"name": "reminder_occurrence_overrides_user_id_original_due_at_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "original_due_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"reminder_occurrence_overrides_user_id_user_id_fk": {
"name": "reminder_occurrence_overrides_user_id_user_id_fk",
"tableFrom": "reminder_occurrence_overrides",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"reminder_occurrence_overrides_reminder_id_reminders_id_fk": {
"name": "reminder_occurrence_overrides_reminder_id_reminders_id_fk",
"tableFrom": "reminder_occurrence_overrides",
"tableTo": "reminders",
"columnsFrom": [
"reminder_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reminder_occurrence_overrides_reminder_id_occurrence_id_unique": {
"name": "reminder_occurrence_overrides_reminder_id_occurrence_id_unique",
"nullsNotDistinct": false,
"columns": [
"reminder_id",
"occurrence_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
@@ -476,4 +766,4 @@
"schemas": {},
"tables": {}
}
}
}

View File

@@ -17,4 +17,4 @@
"breakpoints": true
}
]
}
}

View File

@@ -20,6 +20,7 @@
"@freya/source-google-calendar": "workspace:*",
"@freya/source-google-maps": "workspace:*",
"@freya/source-location": "workspace:*",
"@freya/source-reminders": "workspace:*",
"@freya/source-tfl": "workspace:*",
"@freya/source-weatherkit": "workspace:*",
"@freya/source-web-search": "workspace:*",

View File

@@ -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,
),
],
)

View 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")
})
})

View 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,
})
}
}

View 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)
}

View File

@@ -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!,