11import { Client } from '@notionhq/client' ;
22import { config } from '../config.js' ;
33import { logger } from '../utils/logger.js' ;
4+ import { OpsMeeting , pickMeeting } from './meetings.js' ;
45
56/**
67 * Notion access for the Ops Tasks database (child of "REV12 Operations"). Reads feed the
78 * Sunday digest; the small writers below back the digest's Slack buttons (Done / In
89 * progress / Push a week) so members can update from Slack instead of opening Notion.
910 * One row = one action item a member typed under their own name on a Saturday meeting
1011 * page. Schema: Task (title) · Owner (person) · Status (Not started / In progress /
11- * Done) · Due (date, convention = next Saturday) · Week (date, the Saturday it was
12- * assigned) · Created. Only Status matters for "open": anything not Done is open.
12+ * Done) · Due (date, convention = next Saturday) · Meeting (relation → Ops Meetings row;
13+ * the meeting page's "This week" table is `Meeting contains this page`, its "Carried over"
14+ * table is `Status ≠ Done AND Meeting does not contain this page`) · Week (legacy date) ·
15+ * Created. Only Status matters for "open": anything not Done is open. A task with NO
16+ * Meeting never appears in any "This week" table and looks carried on every page, so the
17+ * Slack writers link it (see `meetingForDate`) and the digest relinks stragglers.
1318 */
1419
1520export interface OpsTaskOwner {
@@ -32,6 +37,8 @@ export interface OpsTask {
3237 */
3338 week : string | null ;
3439 owners : OpsTaskOwner [ ] ;
40+ /** Ops Meetings row ids in the Meeting relation (empty = not linked to any meeting page). */
41+ meetingIds : string [ ] ;
3542}
3643
3744function readTitle ( prop : any ) : string {
@@ -58,6 +65,9 @@ function readCreated(page: any): string | null {
5865 const t = page ?. created_time ;
5966 return typeof t === 'string' ? t . slice ( 0 , 10 ) : null ;
6067}
68+ function readRelationIds ( prop : any ) : string [ ] {
69+ return ( prop ?. relation ?? [ ] ) . map ( ( r : any ) => r ?. id ) . filter ( ( id : any ) => typeof id === 'string' ) ;
70+ }
6171function readOwners ( prop : any ) : OpsTaskOwner [ ] {
6272 return ( prop ?. people ?? [ ] )
6373 . filter ( ( p : any ) => typeof p ?. id === 'string' )
@@ -79,9 +89,15 @@ export function parseOpsTask(page: any): OpsTask {
7989 due : readDate ( p [ 'Due' ] ) ,
8090 week : readRollupDate ( p [ 'Meeting date' ] ) ?? readDate ( p [ 'Week' ] ) ?? readCreated ( page ) ,
8191 owners : readOwners ( p [ 'Owner' ] ) ,
92+ meetingIds : readRelationIds ( p [ 'Meeting' ] ) ,
8293 } ;
8394}
8495
96+ function parseMeeting ( page : any ) : OpsMeeting | null {
97+ const date = readDate ( page ?. properties ?. [ 'Date' ] ) ;
98+ return date ? { id : page . id , date } : null ;
99+ }
100+
85101export class OpsTasksNotion {
86102 private client : Client ;
87103
@@ -106,19 +122,52 @@ export class OpsTasksNotion {
106122 return tasks ;
107123 }
108124
109- /** Create a task (powers `/assign`): Status = Not started, Owner(s), optional Due. Not linked to a meeting page. */
110- async createTask ( input : { title : string ; ownerIds : string [ ] ; dueIso : string | null } ) : Promise < OpsTask > {
125+ /** Every Ops Meetings row with a Date, oldest first (a handful of rows — no need to filter server-side). */
126+ async listMeetings ( ) : Promise < OpsMeeting [ ] > {
127+ const meetings : OpsMeeting [ ] = [ ] ;
128+ let cursor : string | undefined ;
129+ do {
130+ const response : any = await this . client . dataSources . query ( {
131+ data_source_id : config . opsTasks . meetingsDataSourceId ,
132+ filter : { property : 'Date' , date : { is_not_empty : true } } ,
133+ sorts : [ { property : 'Date' , direction : 'ascending' } ] ,
134+ page_size : 100 ,
135+ start_cursor : cursor ,
136+ } ) ;
137+ for ( const page of response . results ?? [ ] ) {
138+ const m = parseMeeting ( page ) ;
139+ if ( m ) meetings . push ( m ) ;
140+ }
141+ cursor = response . has_more ? response . next_cursor ?? undefined : undefined ;
142+ } while ( cursor ) ;
143+ return meetings ;
144+ }
145+
146+ /** The meeting page a task dated `anchorIso` belongs to (see meetings.ts), or null if that page doesn't exist yet. */
147+ async meetingForDate ( anchorIso : string ) : Promise < OpsMeeting | null > {
148+ return pickMeeting ( await this . listMeetings ( ) , anchorIso ) ;
149+ }
150+
151+ /**
152+ * Create a task (powers `/assign`): Status = Not started, Owner(s), optional Due, and the
153+ * Meeting relation when the caller found the week's page (so it shows in that page's
154+ * "This week" table instead of looking carried over).
155+ */
156+ async createTask ( input : { title : string ; ownerIds : string [ ] ; dueIso : string | null ; meetingId ?: string | null } ) : Promise < OpsTask > {
111157 const properties : Record < string , any > = {
112158 Task : { title : [ { text : { content : input . title . slice ( 0 , 200 ) } } ] } ,
113159 Owner : { people : input . ownerIds . map ( ( id ) => ( { id } ) ) } ,
114160 Status : { status : { name : 'Not started' } } ,
115161 } ;
116162 if ( input . dueIso ) properties [ 'Due' ] = { date : { start : input . dueIso } } ;
163+ if ( input . meetingId ) properties [ 'Meeting' ] = { relation : [ { id : input . meetingId } ] } ;
117164 const page : any = await this . client . pages . create ( {
118165 parent : { type : 'data_source_id' , data_source_id : config . opsTasks . dataSourceId } ,
119166 properties : properties as any ,
120167 } ) ;
121- logger . info ( `Ops task created: "${ input . title } " → ${ input . ownerIds . length } owner(s), due ${ input . dueIso ?? 'none' } .` ) ;
168+ logger . info (
169+ `Ops task created: "${ input . title } " → ${ input . ownerIds . length } owner(s), due ${ input . dueIso ?? 'none' } , meeting ${ input . meetingId ?? 'none' } .`
170+ ) ;
122171 return parseOpsTask ( page ) ;
123172 }
124173
@@ -134,6 +183,14 @@ export class OpsTasksNotion {
134183 logger . info ( `Ops task ${ pageId } → Status ${ status } .` ) ;
135184 }
136185
186+ /** Add a meeting page to the task's Meeting relation (keeps existing links; no-op if already linked). */
187+ async linkMeeting ( task : OpsTask , meetingId : string ) : Promise < void > {
188+ if ( task . meetingIds . includes ( meetingId ) ) return ;
189+ const relation = [ ...task . meetingIds , meetingId ] . map ( ( id ) => ( { id } ) ) ;
190+ await this . client . pages . update ( { page_id : task . id , properties : { Meeting : { relation } } as any } ) ;
191+ logger . info ( `Ops task ${ task . id } → linked to meeting ${ meetingId } .` ) ;
192+ }
193+
137194 /** Set the Due date (YYYY-MM-DD). */
138195 async setDue ( pageId : string , dueIso : string ) : Promise < void > {
139196 await this . client . pages . update ( { page_id : pageId , properties : { Due : { date : { start : dueIso } } } as any } ) ;
0 commit comments