diff --git a/appview/index.ts b/appview/index.ts index c5cccae0..ffca659d 100644 --- a/appview/index.ts +++ b/appview/index.ts @@ -4,7 +4,11 @@ import { IdResolver } from "@atproto/identity"; const idResolver = new IdResolver(); import { Firehose, MemoryRunner } from "@atproto/sync"; import { ids } from "lexicons/api/lexicons"; -import { PubLeafletDocument, PubLeafletPublication } from "lexicons/api"; +import { + PubLeafletDocument, + PubLeafletPublication, + PubLeafletPublicationSubscription, +} from "lexicons/api"; import { AtUri } from "@atproto/syntax"; import { writeFile, readFile } from "fs/promises"; @@ -31,7 +35,11 @@ async function main() { excludeIdentity: true, runner, idResolver, - filterCollections: [ids.PubLeafletDocument, ids.PubLeafletPublication], + filterCollections: [ + ids.PubLeafletDocument, + ids.PubLeafletPublication, + ids.PubLeafletPublicationSubscription, + ], handleEvent: async (evt) => { if ( evt.event == "account" || @@ -86,6 +94,26 @@ async function main() { .eq("uri", evt.uri.toString()); } } + if (evt.collection === ids.PubLeafletPublication) { + if (evt.event === "create" || evt.event === "update") { + let record = PubLeafletPublicationSubscription.validateRecord( + evt.record, + ); + if (!record.success) return; + await supabase.from("publication_subscriptions").upsert({ + uri: evt.uri.toString(), + identity: evt.did, + publication: record.value.publication, + record: record.value as Json, + }); + } + if (evt.event === "delete") { + await supabase + .from("publication_subscriptions") + .delete() + .eq("uri", evt.uri.toString()); + } + } }, onError: (err) => { console.error(err); diff --git a/drizzle/relations.ts b/drizzle/relations.ts index 1a0d8a8e..c40b821d 100644 --- a/drizzle/relations.ts +++ b/drizzle/relations.ts @@ -1,5 +1,5 @@ import { relations } from "drizzle-orm/relations"; -import { entities, facts, entity_sets, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, poll_votes_on_entity, subscribers_to_publications, publications, permission_token_on_homepage, documents, documents_in_publications, publication_domains, leaflets_in_publications, permission_token_rights } from "./schema"; +import { entities, facts, entity_sets, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, custom_domains, phone_rsvps_to_entity, custom_domain_routes, poll_votes_on_entity, subscribers_to_publications, publications, permission_token_on_homepage, documents, documents_in_publications, publication_domains, publication_subscriptions, leaflets_in_publications, permission_token_rights } from "./schema"; export const factsRelations = relations(facts, ({one}) => ({ entity: one(entities, { @@ -78,6 +78,15 @@ export const email_auth_tokensRelations = relations(email_auth_tokens, ({one}) = }), })); +export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({ + identity: one(identities, { + fields: [custom_domains.identity], + references: [identities.email] + }), + custom_domain_routes: many(custom_domain_routes), + publication_domains: many(publication_domains), +})); + export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ entity: one(entities, { fields: [phone_rsvps_to_entity.entity], @@ -102,15 +111,6 @@ export const custom_domain_routesRelations = relations(custom_domain_routes, ({o }), })); -export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({ - custom_domain_routes: many(custom_domain_routes), - identity: one(identities, { - fields: [custom_domains.identity], - references: [identities.email] - }), - publication_domains: many(publication_domains), -})); - export const poll_votes_on_entityRelations = relations(poll_votes_on_entity, ({one}) => ({ entity_option_entity: one(entities, { fields: [poll_votes_on_entity.option_entity], @@ -139,6 +139,7 @@ export const publicationsRelations = relations(publications, ({many}) => ({ subscribers_to_publications: many(subscribers_to_publications), documents_in_publications: many(documents_in_publications), publication_domains: many(publication_domains), + publication_subscriptions: many(publication_subscriptions), leaflets_in_publications: many(leaflets_in_publications), })); @@ -184,6 +185,13 @@ export const publication_domainsRelations = relations(publication_domains, ({one }), })); +export const publication_subscriptionsRelations = relations(publication_subscriptions, ({one}) => ({ + publication: one(publications, { + fields: [publication_subscriptions.publication], + references: [publications.uri] + }), +})); + export const leaflets_in_publicationsRelations = relations(leaflets_in_publications, ({one}) => ({ document: one(documents, { fields: [leaflets_in_publications.doc], diff --git a/drizzle/schema.ts b/drizzle/schema.ts index c54ed595..01f8a5cd 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -115,6 +115,13 @@ export const phone_number_auth_tokens = pgTable("phone_number_auth_tokens", { country_code: text("country_code").notNull(), }); +export const custom_domains = pgTable("custom_domains", { + domain: text("domain").primaryKey().notNull(), + identity: text("identity").default('').references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), + confirmed: boolean("confirmed").notNull(), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}); + export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", { created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), phone_number: text("phone_number").notNull(), @@ -145,13 +152,6 @@ export const custom_domain_routes = pgTable("custom_domain_routes", { } }); -export const custom_domains = pgTable("custom_domains", { - domain: text("domain").primaryKey().notNull(), - identity: text("identity").default('').references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), - confirmed: boolean("confirmed").notNull(), - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), -}); - export const poll_votes_on_entity = pgTable("poll_votes_on_entity", { id: uuid("id").defaultRandom().primaryKey().notNull(), created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), @@ -205,9 +205,23 @@ export const publication_domains = pgTable("publication_domains", { } }); +export const publication_subscriptions = pgTable("publication_subscriptions", { + publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade" } ), + identity: text("identity").notNull(), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + record: jsonb("record").notNull(), + uri: text("uri"), +}, +(table) => { + return { + publication_subscriptions_pkey: primaryKey({ columns: [table.publication, table.identity], name: "publication_subscriptions_pkey"}), + publication_subscriptions_uri_key: unique("publication_subscriptions_uri_key").on(table.uri), + } +}); + export const leaflets_in_publications = pgTable("leaflets_in_publications", { publication: text("publication").notNull().references(() => publications.uri), - doc: text("doc").default('').references(() => documents.uri), + doc: text("doc").default('').references(() => documents.uri, { onDelete: "set null" } ), leaflet: uuid("leaflet").notNull().references(() => permission_tokens.id), description: text("description").default('').notNull(), title: text("title").default('').notNull(), diff --git a/lexicons/api/index.ts b/lexicons/api/index.ts index 9964bd83..879ac53c 100644 --- a/lexicons/api/index.ts +++ b/lexicons/api/index.ts @@ -12,6 +12,7 @@ import * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' import * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' import * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' import * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' +import * as PubLeafletPublicationSubscription from './types/pub/leaflet/publication/subscription' import * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' import * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' import * as ComAtprotoRepoApplyWrites from './types/com/atproto/repo/applyWrites' @@ -34,6 +35,7 @@ export * as PubLeafletBlocksImage from './types/pub/leaflet/blocks/image' export * as PubLeafletBlocksText from './types/pub/leaflet/blocks/text' export * as PubLeafletBlocksUnorderedList from './types/pub/leaflet/blocks/unorderedList' export * as PubLeafletPagesLinearDocument from './types/pub/leaflet/pages/linearDocument' +export * as PubLeafletPublicationSubscription from './types/pub/leaflet/publication/subscription' export * as PubLeafletRichtextFacet from './types/pub/leaflet/richtext/facet' export * as ComAtprotoLabelDefs from './types/com/atproto/label/defs' export * as ComAtprotoRepoApplyWrites from './types/com/atproto/repo/applyWrites' @@ -89,12 +91,14 @@ export class PubLeafletNS { publication: PublicationRecord blocks: PubLeafletBlocksNS pages: PubLeafletPagesNS + publication: PubLeafletPublicationNS richtext: PubLeafletRichtextNS constructor(client: XrpcClient) { this._client = client this.blocks = new PubLeafletBlocksNS(client) this.pages = new PubLeafletPagesNS(client) + this.publication = new PubLeafletPublicationNS(client) this.richtext = new PubLeafletRichtextNS(client) this.document = new DocumentRecord(client) this.publication = new PublicationRecord(client) @@ -117,6 +121,81 @@ export class PubLeafletPagesNS { } } +export class PubLeafletPublicationNS { + _client: XrpcClient + subscription: SubscriptionRecord + + constructor(client: XrpcClient) { + this._client = client + this.subscription = new SubscriptionRecord(client) + } +} + +export class SubscriptionRecord { + _client: XrpcClient + + constructor(client: XrpcClient) { + this._client = client + } + + async list( + params: OmitKey, + ): Promise<{ + cursor?: string + records: { uri: string; value: PubLeafletPublicationSubscription.Record }[] + }> { + const res = await this._client.call('com.atproto.repo.listRecords', { + collection: 'pub.leaflet.publication.subscription', + ...params, + }) + return res.data + } + + async get( + params: OmitKey, + ): Promise<{ + uri: string + cid: string + value: PubLeafletPublicationSubscription.Record + }> { + const res = await this._client.call('com.atproto.repo.getRecord', { + collection: 'pub.leaflet.publication.subscription', + ...params, + }) + return res.data + } + + async create( + params: OmitKey< + ComAtprotoRepoCreateRecord.InputSchema, + 'collection' | 'record' + >, + record: Un$Typed, + headers?: Record, + ): Promise<{ uri: string; cid: string }> { + const collection = 'pub.leaflet.publication.subscription' + const res = await this._client.call( + 'com.atproto.repo.createRecord', + undefined, + { collection, ...params, record: { ...record, $type: collection } }, + { encoding: 'application/json', headers }, + ) + return res.data + } + + async delete( + params: OmitKey, + headers?: Record, + ): Promise { + await this._client.call( + 'com.atproto.repo.deleteRecord', + undefined, + { collection: 'pub.leaflet.publication.subscription', ...params }, + { headers }, + ) + } +} + export class PubLeafletRichtextNS { _client: XrpcClient diff --git a/lexicons/api/lexicons.ts b/lexicons/api/lexicons.ts index e92a7f1c..4549e71c 100644 --- a/lexicons/api/lexicons.ts +++ b/lexicons/api/lexicons.ts @@ -29,6 +29,10 @@ export const schemaDict = { maxLength: 1280, maxGraphemes: 128, }, + postRef: { + type: 'ref', + ref: 'lex:com.atproto.repo.strongRef', + }, description: { type: 'string', maxLength: 3000, @@ -269,6 +273,27 @@ export const schemaDict = { }, }, }, + PubLeafletPublicationSubscription: { + lexicon: 1, + id: 'pub.leaflet.publication.subscription', + defs: { + main: { + type: 'record', + key: 'tid', + description: 'Record declaring a subscription to a publication', + record: { + type: 'object', + required: ['publication'], + properties: { + publication: { + type: 'string', + format: 'at-uri', + }, + }, + }, + }, + }, + }, PubLeafletRichtextFacet: { lexicon: 1, id: 'pub.leaflet.richtext.facet', @@ -1344,6 +1369,7 @@ export const ids = { PubLeafletBlocksText: 'pub.leaflet.blocks.text', PubLeafletBlocksUnorderedList: 'pub.leaflet.blocks.unorderedList', PubLeafletPagesLinearDocument: 'pub.leaflet.pages.linearDocument', + PubLeafletPublicationSubscription: 'pub.leaflet.publication.subscription', PubLeafletRichtextFacet: 'pub.leaflet.richtext.facet', ComAtprotoLabelDefs: 'com.atproto.label.defs', ComAtprotoRepoApplyWrites: 'com.atproto.repo.applyWrites', diff --git a/lexicons/api/types/pub/leaflet/document.ts b/lexicons/api/types/pub/leaflet/document.ts index 67f6584a..e4fcda12 100644 --- a/lexicons/api/types/pub/leaflet/document.ts +++ b/lexicons/api/types/pub/leaflet/document.ts @@ -5,6 +5,7 @@ import { ValidationResult, BlobRef } from '@atproto/lexicon' import { CID } from 'multiformats/cid' import { validate as _validate } from '../../../lexicons' import { $Typed, is$typed as _is$typed, OmitKey } from '../../../util' +import type * as ComAtprotoRepoStrongRef from '../../com/atproto/repo/strongRef' import type * as PubLeafletPagesLinearDocument from './pages/linearDocument' const is$typed = _is$typed, @@ -14,6 +15,7 @@ const id = 'pub.leaflet.document' export interface Record { $type: 'pub.leaflet.document' title: string + postRef?: ComAtprotoRepoStrongRef.Main description?: string publishedAt?: string publication: string diff --git a/lexicons/api/types/pub/leaflet/publication/subscription.ts b/lexicons/api/types/pub/leaflet/publication/subscription.ts new file mode 100644 index 00000000..5aeedd28 --- /dev/null +++ b/lexicons/api/types/pub/leaflet/publication/subscription.ts @@ -0,0 +1,27 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { CID } from 'multiformats/cid' +import { validate as _validate } from '../../../../lexicons' +import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util' + +const is$typed = _is$typed, + validate = _validate +const id = 'pub.leaflet.publication.subscription' + +export interface Record { + $type: 'pub.leaflet.publication.subscription' + publication: string + [k: string]: unknown +} + +const hashRecord = 'main' + +export function isRecord(v: V) { + return is$typed(v, id, hashRecord) +} + +export function validateRecord(v: V) { + return validate(v, id, hashRecord, true) +} diff --git a/lexicons/pub/leaflet/document.json b/lexicons/pub/leaflet/document.json index 23791045..c7c3699e 100644 --- a/lexicons/pub/leaflet/document.json +++ b/lexicons/pub/leaflet/document.json @@ -22,6 +22,10 @@ "maxLength": 1280, "maxGraphemes": 128 }, + "postRef": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, "description": { "type": "string", "maxLength": 3000, diff --git a/lexicons/pub/leaflet/publication/subscription.json b/lexicons/pub/leaflet/publication/subscription.json new file mode 100644 index 00000000..1d620a66 --- /dev/null +++ b/lexicons/pub/leaflet/publication/subscription.json @@ -0,0 +1,23 @@ +{ + "lexicon": 1, + "id": "pub.leaflet.publication.subscription", + "defs": { + "main": { + "type": "record", + "key": "tid", + "description": "Record declaring a subscription to a publication", + "record": { + "type": "object", + "required": [ + "publication" + ], + "properties": { + "publication": { + "type": "string", + "format": "at-uri" + } + } + } + } + } +} \ No newline at end of file diff --git a/lexicons/src/document.ts b/lexicons/src/document.ts index 9f71b7c1..d3dba610 100644 --- a/lexicons/src/document.ts +++ b/lexicons/src/document.ts @@ -16,6 +16,7 @@ export const PubLeafletDocument: LexiconDoc = { required: ["pages", "author", "title", "publication"], properties: { title: { type: "string", maxLength: 1280, maxGraphemes: 128 }, + postRef: { type: "ref", ref: "com.atproto.repo.strongRef" }, description: { type: "string", maxLength: 3000, maxGraphemes: 300 }, publishedAt: { type: "string", format: "datetime" }, publication: { type: "string", format: "at-uri" }, diff --git a/lexicons/src/publication.ts b/lexicons/src/publication.ts index a3b8e8f7..5c69036a 100644 --- a/lexicons/src/publication.ts +++ b/lexicons/src/publication.ts @@ -21,3 +21,22 @@ export const PubLeafletPublication: LexiconDoc = { }, }, }; + +export const PubLeafletPublicationSubscription: LexiconDoc = { + lexicon: 1, + id: "pub.leaflet.publication.subscription", + defs: { + main: { + type: "record", + key: "tid", + description: "Record declaring a subscription to a publication", + record: { + type: "object", + required: ["publication"], + properties: { + publication: { type: "string", format: "at-uri" }, + }, + }, + }, + }, +}; diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 11e26291..433f0958 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -684,6 +684,38 @@ export type Database = { }, ] } + publication_subscriptions: { + Row: { + created_at: string + identity: string + publication: string + record: Json + uri: string | null + } + Insert: { + created_at?: string + identity: string + publication: string + record: Json + uri?: string | null + } + Update: { + created_at?: string + identity?: string + publication?: string + record?: Json + uri?: string | null + } + Relationships: [ + { + foreignKeyName: "publication_subscriptions_publication_fkey" + columns: ["publication"] + isOneToOne: false + referencedRelation: "publications" + referencedColumns: ["uri"] + }, + ] + } publications: { Row: { identity_did: string diff --git a/supabase/migrations/20250605003641_add_publication_subscriptions_table.sql b/supabase/migrations/20250605003641_add_publication_subscriptions_table.sql new file mode 100644 index 00000000..6c0ab103 --- /dev/null +++ b/supabase/migrations/20250605003641_add_publication_subscriptions_table.sql @@ -0,0 +1,64 @@ +create table "public"."publication_subscriptions" ( + "publication" text not null, + "identity" text not null, + "created_at" timestamp with time zone not null default now(), + "record" jsonb not null, + "uri" text +); + + +alter table "public"."publication_subscriptions" enable row level security; + +CREATE UNIQUE INDEX publication_subscriptions_pkey ON public.publication_subscriptions USING btree (publication, identity); + +CREATE UNIQUE INDEX publication_subscriptions_uri_key ON public.publication_subscriptions USING btree (uri); + +alter table "public"."publication_subscriptions" add constraint "publication_subscriptions_pkey" PRIMARY KEY using index "publication_subscriptions_pkey"; + +alter table "public"."publication_subscriptions" add constraint "publication_subscriptions_publication_fkey" FOREIGN KEY (publication) REFERENCES publications(uri) ON DELETE CASCADE not valid; + +alter table "public"."publication_subscriptions" validate constraint "publication_subscriptions_publication_fkey"; + +alter table "public"."publication_subscriptions" add constraint "publication_subscriptions_uri_key" UNIQUE using index "publication_subscriptions_uri_key"; + +grant delete on table "public"."publication_subscriptions" to "anon"; + +grant insert on table "public"."publication_subscriptions" to "anon"; + +grant references on table "public"."publication_subscriptions" to "anon"; + +grant select on table "public"."publication_subscriptions" to "anon"; + +grant trigger on table "public"."publication_subscriptions" to "anon"; + +grant truncate on table "public"."publication_subscriptions" to "anon"; + +grant update on table "public"."publication_subscriptions" to "anon"; + +grant delete on table "public"."publication_subscriptions" to "authenticated"; + +grant insert on table "public"."publication_subscriptions" to "authenticated"; + +grant references on table "public"."publication_subscriptions" to "authenticated"; + +grant select on table "public"."publication_subscriptions" to "authenticated"; + +grant trigger on table "public"."publication_subscriptions" to "authenticated"; + +grant truncate on table "public"."publication_subscriptions" to "authenticated"; + +grant update on table "public"."publication_subscriptions" to "authenticated"; + +grant delete on table "public"."publication_subscriptions" to "service_role"; + +grant insert on table "public"."publication_subscriptions" to "service_role"; + +grant references on table "public"."publication_subscriptions" to "service_role"; + +grant select on table "public"."publication_subscriptions" to "service_role"; + +grant trigger on table "public"."publication_subscriptions" to "service_role"; + +grant truncate on table "public"."publication_subscriptions" to "service_role"; + +grant update on table "public"."publication_subscriptions" to "service_role";