From 76bcd3c52c0a5913a6e3c31a1a202ac8b00c1b50 Mon Sep 17 00:00:00 2001
From: Denis Bykhov <bykhov.denis@gmail.com>
Date: Tue, 12 Nov 2024 22:40:15 +0500
Subject: [PATCH] Improve calendar schema (#7156)

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
---
 models/calendar/src/index.ts                  |  3 +-
 models/calendar/src/migration.ts              | 12 +++-
 .../src/components/review/CreateReview.svelte |  8 +--
 server/postgres/migrations/calendarSchema.sql |  5 ++
 server/postgres/migrations/eventSchema.sql    | 19 ++++++
 server/postgres/migrations/timeSchema.sql     | 17 ++++++
 server/postgres/src/schemas.ts                | 60 +++++++++++++++++++
 server/postgres/src/storage.ts                | 32 ++++++----
 server/postgres/src/utils.ts                  | 22 ++++---
 9 files changed, 151 insertions(+), 27 deletions(-)
 create mode 100644 server/postgres/migrations/calendarSchema.sql
 create mode 100644 server/postgres/migrations/eventSchema.sql
 create mode 100644 server/postgres/migrations/timeSchema.sql

diff --git a/models/calendar/src/index.ts b/models/calendar/src/index.ts
index b2c9099059..d5ab86ce8d 100644
--- a/models/calendar/src/index.ts
+++ b/models/calendar/src/index.ts
@@ -68,6 +68,7 @@ export { calendarId } from '@hcengineering/calendar'
 export { calendarOperation } from './migration'
 
 export const DOMAIN_CALENDAR = 'calendar' as Domain
+export const DOMAIN_EVENT = 'event' as Domain
 
 @Model(calendar.class.Calendar, core.class.Doc, DOMAIN_CALENDAR)
 @UX(calendar.string.Calendar, calendar.icon.Calendar)
@@ -85,7 +86,7 @@ export class TExternalCalendar extends TCalendar implements ExternalCalendar {
   externalUser!: string
 }
 
-@Model(calendar.class.Event, core.class.AttachedDoc, DOMAIN_CALENDAR)
+@Model(calendar.class.Event, core.class.AttachedDoc, DOMAIN_EVENT)
 @UX(calendar.string.Event, calendar.icon.Calendar)
 export class TEvent extends TAttachedDoc implements Event {
   declare space: Ref<SystemSpace>
diff --git a/models/calendar/src/migration.ts b/models/calendar/src/migration.ts
index de39d23ec4..47b3d13706 100644
--- a/models/calendar/src/migration.ts
+++ b/models/calendar/src/migration.ts
@@ -24,7 +24,7 @@ import {
   type MigrationUpgradeClient
 } from '@hcengineering/model'
 import { DOMAIN_SPACE } from '@hcengineering/model-core'
-import { DOMAIN_CALENDAR } from '.'
+import { DOMAIN_CALENDAR, DOMAIN_EVENT } from '.'
 import calendar from './plugin'
 
 async function migrateCalendars (client: MigrationClient): Promise<void> {
@@ -136,6 +136,16 @@ export const calendarOperation: MigrateOperation = {
       {
         state: 'migrate_calendars',
         func: migrateCalendars
+      },
+      {
+        state: 'move-events',
+        func: async (client) => {
+          await client.move(
+            DOMAIN_CALENDAR,
+            { _class: { $in: client.hierarchy.getDescendants(calendar.class.Event) } },
+            DOMAIN_EVENT
+          )
+        }
       }
     ])
   },
diff --git a/plugins/recruit-resources/src/components/review/CreateReview.svelte b/plugins/recruit-resources/src/components/review/CreateReview.svelte
index b74bef5342..b00d4ab55a 100644
--- a/plugins/recruit-resources/src/components/review/CreateReview.svelte
+++ b/plugins/recruit-resources/src/components/review/CreateReview.svelte
@@ -13,7 +13,7 @@
 // limitations under the License.
 -->
 <script lang="ts">
-  import calendar from '@hcengineering/calendar'
+  import calendar, { Calendar } from '@hcengineering/calendar'
   import type { Contact, PersonAccount, Organization, Person } from '@hcengineering/contact'
   import contact from '@hcengineering/contact'
   import core, {
@@ -41,7 +41,6 @@
   import recruit from '../../plugin'
   import IconCompany from '../icons/Company.svelte'
   import { Analytics } from '@hcengineering/analytics'
-  import { getCandidateIdentifier } from '../../utils'
 
   // export let space: Ref<Project>
   export let candidate: Ref<Person>
@@ -86,7 +85,8 @@
     title,
     participants: [currentUser.person],
     eventId: '',
-    dueDate: 0
+    dueDate: 0,
+    calendar: '' as Ref<Calendar>
   }
 
   const dispatch = createEventDispatcher()
@@ -139,7 +139,7 @@
         access: 'reader',
         allDay: false,
         eventId: '',
-        calendar: undefined
+        calendar: '' as Ref<Calendar>
       }
     )
 
diff --git a/server/postgres/migrations/calendarSchema.sql b/server/postgres/migrations/calendarSchema.sql
new file mode 100644
index 0000000000..ea9a714ed5
--- /dev/null
+++ b/server/postgres/migrations/calendarSchema.sql
@@ -0,0 +1,5 @@
+ALTER TABLE calendar 
+ADD "hidden" bool;
+
+UPDATE calendar
+SET "hidden" = (data->>'hidden')::boolean;
diff --git a/server/postgres/migrations/eventSchema.sql b/server/postgres/migrations/eventSchema.sql
new file mode 100644
index 0000000000..5a8c1ce9f0
--- /dev/null
+++ b/server/postgres/migrations/eventSchema.sql
@@ -0,0 +1,19 @@
+ALTER TABLE event 
+ADD "date" bigint,
+ADD "dueDate" bigint,
+add calendar text,
+ADD "participants" text[];
+
+UPDATE event
+SET "date" = (data->>'date')::bigint;
+
+UPDATE event
+SET "dueDate" = (data->>'dueDate')::bigint;
+
+UPDATE calendar
+SET "calendar" = (data->>'calendar');
+
+UPDATE event
+SET "participants" = array(
+    SELECT jsonb_array_elements_text(data->'participants')
+);
diff --git a/server/postgres/migrations/timeSchema.sql b/server/postgres/migrations/timeSchema.sql
new file mode 100644
index 0000000000..3a6d722293
--- /dev/null
+++ b/server/postgres/migrations/timeSchema.sql
@@ -0,0 +1,17 @@
+ALTER TABLE time 
+ADD "workslots" bigint,
+ADD "doneOn" bigint,
+add rank text,
+ADD "user" text;
+
+UPDATE time
+SET "workslots" = (data->>'workslots')::bigint;
+
+UPDATE time
+SET "doneOn" = (data->>'doneOn')::bigint;
+
+UPDATE time
+SET "rank" = (data->>'rank');
+
+UPDATE time
+SET "user" = (data->>'user');
diff --git a/server/postgres/src/schemas.ts b/server/postgres/src/schemas.ts
index 4adb6d0c0f..0a3e482db0 100644
--- a/server/postgres/src/schemas.ts
+++ b/server/postgres/src/schemas.ts
@@ -155,6 +155,63 @@ const docIndexStateSchema: Schema = {
   }
 }
 
+const timeSchema: Schema = {
+  ...baseSchema,
+  workslots: {
+    type: 'bigint',
+    notNull: false,
+    index: true
+  },
+  doneOn: {
+    type: 'bigint',
+    notNull: false,
+    index: true
+  },
+  user: {
+    type: 'text',
+    notNull: true,
+    index: true
+  },
+  rank: {
+    type: 'text',
+    notNull: true,
+    index: false
+  }
+}
+
+const calendarSchema: Schema = {
+  ...baseSchema,
+  hidden: {
+    type: 'bool',
+    notNull: true,
+    index: true
+  }
+}
+
+const eventSchema: Schema = {
+  ...defaultSchema,
+  calendar: {
+    type: 'text',
+    notNull: true,
+    index: true
+  },
+  date: {
+    type: 'bigint',
+    notNull: true,
+    index: true
+  },
+  dueDate: {
+    type: 'bigint',
+    notNull: true,
+    index: true
+  },
+  participants: {
+    type: 'text[]',
+    notNull: true,
+    index: true
+  }
+}
+
 export function addSchema (domain: string, schema: Schema): void {
   domainSchemas[translateDomain(domain)] = schema
 }
@@ -166,6 +223,9 @@ export function translateDomain (domain: string): string {
 export const domainSchemas: Record<string, Schema> = {
   [DOMAIN_SPACE]: spaceSchema,
   [DOMAIN_TX]: txSchema,
+  [translateDomain('time')]: timeSchema,
+  [translateDomain('calendar')]: calendarSchema,
+  [translateDomain('event')]: eventSchema,
   [translateDomain(DOMAIN_DOC_INDEX_STATE)]: docIndexStateSchema,
   notification: notificationSchema,
   [translateDomain('notification-dnc')]: dncSchema,
diff --git a/server/postgres/src/storage.ts b/server/postgres/src/storage.ts
index 827136a4bd..412292c046 100644
--- a/server/postgres/src/storage.ts
+++ b/server/postgres/src/storage.ts
@@ -210,7 +210,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
         await close(cursorName)
         return null
       }
-      return result.map((p) => parseDoc(p as any))
+      return result.map((p) => parseDoc(p as any, _domain))
     }
 
     await init()
@@ -254,7 +254,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
     }
     const finalSql: string = [select, ...sqlChunks].join(' ')
     const result = await this.client.unsafe(finalSql)
-    return result.map((p) => parseDocWithProjection(p as any, options?.projection))
+    return result.map((p) => parseDocWithProjection(p as any, domain, options?.projection))
   }
 
   buildRawOrder<T extends Doc>(domain: string, sort: SortingQuery<T>): string {
@@ -307,7 +307,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
           const res = await client.unsafe(
             `SELECT * FROM ${translateDomain(domain)} WHERE ${translatedQuery} FOR UPDATE`
           )
-          const docs = res.map((p) => parseDoc(p as any))
+          const docs = res.map((p) => parseDoc(p as any, domain))
           for (const doc of docs) {
             if (doc === undefined) continue
             const prevAttachedTo = (doc as any).attachedTo
@@ -460,11 +460,11 @@ abstract class PostgresAdapterBase implements DbAdapter {
         const result = await connection.unsafe(finalSql)
         if (options?.lookup === undefined && options?.domainLookup === undefined) {
           return toFindResult(
-            result.map((p) => parseDocWithProjection(p as any, options?.projection)),
+            result.map((p) => parseDocWithProjection(p as any, domain, options?.projection)),
             total
           )
         } else {
-          const res = this.parseLookup<T>(result, joins, options?.projection)
+          const res = this.parseLookup<T>(result, joins, options?.projection, domain)
           return toFindResult(res, total)
         }
       } catch (err) {
@@ -493,7 +493,8 @@ abstract class PostgresAdapterBase implements DbAdapter {
   private parseLookup<T extends Doc>(
     rows: any[],
     joins: JoinProps[],
-    projection: Projection<T> | undefined
+    projection: Projection<T> | undefined,
+    domain: string
   ): WithLookup<T>[] {
     const map = new Map<Ref<T>, WithLookup<T>>()
     const modelJoins: JoinProps[] = []
@@ -526,7 +527,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
               if (res === undefined) continue
               const { obj, key } = res
 
-              const parsed = row[column].map(parseDoc)
+              const parsed = row[column].map((p: any) => parseDoc(p, domain))
               obj[key] = parsed
             }
           } else if (column.startsWith('lookup_')) {
@@ -982,7 +983,11 @@ abstract class PostgresAdapterBase implements DbAdapter {
         const val = value[operator]
         switch (operator) {
           case '$ne':
-            res.push(`${tkey} != '${val}'`)
+            if (val === null) {
+              res.push(`${tkey} IS NOT NULL`)
+            } else {
+              res.push(`${tkey} != '${val}'`)
+            }
             break
           case '$gt':
             res.push(`${tkey} > '${val}'`)
@@ -1131,7 +1136,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
       if (result.length === 0) {
         return []
       }
-      return result.filter((it) => it != null).map((it) => parseDoc(it as any))
+      return result.filter((it) => it != null).map((it) => parseDoc(it as any, domain))
     }
 
     const flush = async (flush = false): Promise<void> => {
@@ -1221,7 +1226,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
       const connection = (await this.getConnection(ctx)) ?? this.client
       const res =
         await connection`SELECT * FROM ${connection(translateDomain(domain))} WHERE _id = ANY(${docs}) AND "workspaceId" = ${this.workspaceId.name}`
-      return res.map((p) => parseDocWithProjection(p as any))
+      return res.map((p) => parseDocWithProjection(p as any, domain))
     })
   }
 
@@ -1306,7 +1311,7 @@ abstract class PostgresAdapterBase implements DbAdapter {
         try {
           const res =
             await client`SELECT * FROM ${client(translateDomain(domain))} WHERE _id = ANY(${ids}) AND "workspaceId" = ${this.workspaceId.name} FOR UPDATE`
-          const docs = res.map((p) => parseDoc(p as any))
+          const docs = res.map((p) => parseDoc(p as any, domain))
           const map = new Map(docs.map((d) => [d._id, d]))
           for (const [_id, ops] of operations) {
             const doc = map.get(_id)
@@ -1581,7 +1586,8 @@ class PostgresAdapter extends PostgresAdapterBase {
           forUpdate ? client` FOR UPDATE` : client``
         }`
       const dbDoc = res[0]
-      return dbDoc !== undefined ? parseDoc(dbDoc as any) : undefined
+      const domain = this.hierarchy.getDomain(_class)
+      return dbDoc !== undefined ? parseDoc(dbDoc as any, domain) : undefined
     })
   }
 
@@ -1621,7 +1627,7 @@ class PostgresTxAdapter extends PostgresAdapterBase implements TxAdapter {
     const res = await this
       .client`SELECT * FROM ${this.client(translateDomain(DOMAIN_TX))} WHERE "workspaceId" = ${this.workspaceId.name} AND "objectSpace" = ${core.space.Model} ORDER BY _id ASC, "modifiedOn" ASC`
 
-    const model = res.map((p) => parseDoc<Tx>(p as any))
+    const model = res.map((p) => parseDoc<Tx>(p as any, DOMAIN_TX))
     // We need to put all core.account.System transactions first
     const systemTx: Tx[] = []
     const userTx: Tx[] = []
diff --git a/server/postgres/src/utils.ts b/server/postgres/src/utils.ts
index 77dedcad4c..9a5499d569 100644
--- a/server/postgres/src/utils.ts
+++ b/server/postgres/src/utils.ts
@@ -326,8 +326,8 @@ export function parseUpdate<T extends Doc> (
   const remainingData: Partial<T> = {}
 
   for (const key in ops) {
-    if (key === '$push' || key === '$pull') {
-      const val = (ops as any)[key]
+    const val = (ops as any)[key]
+    if (key.startsWith('$')) {
       for (const k in val) {
         if (getDocFieldsByDomains(domain).includes(k)) {
           ;(extractedFields as any)[k] = val[key]
@@ -337,9 +337,9 @@ export function parseUpdate<T extends Doc> (
       }
     } else {
       if (getDocFieldsByDomains(domain).includes(key)) {
-        ;(extractedFields as any)[key] = (ops as any)[key]
+        ;(extractedFields as any)[key] = val
       } else {
-        ;(remainingData as any)[key] = (ops as any)[key]
+        ;(remainingData as any)[key] = val
       }
     }
   }
@@ -396,8 +396,13 @@ export class DBCollectionHelper implements DomainHelperOperations {
   }
 }
 
-export function parseDocWithProjection<T extends Doc> (doc: DBDoc, projection?: Projection<T> | undefined): T {
+export function parseDocWithProjection<T extends Doc> (
+  doc: DBDoc,
+  domain: string,
+  projection?: Projection<T> | undefined
+): T {
   const { workspaceId, data, ...rest } = doc
+  const schema = getSchema(domain)
   for (const key in rest) {
     if ((rest as any)[key] === 'NULL' || (rest as any)[key] === null) {
       if (key === 'attachedTo') {
@@ -407,7 +412,7 @@ export function parseDocWithProjection<T extends Doc> (doc: DBDoc, projection?:
         ;(rest as any)[key] = null
       }
     }
-    if (key === 'modifiedOn' || key === 'createdOn') {
+    if (schema[key] !== undefined && schema[key].type === 'bigint') {
       ;(rest as any)[key] = Number.parseInt((rest as any)[key])
     }
   }
@@ -427,7 +432,8 @@ export function parseDocWithProjection<T extends Doc> (doc: DBDoc, projection?:
   return res
 }
 
-export function parseDoc<T extends Doc> (doc: DBDoc): T {
+export function parseDoc<T extends Doc> (doc: DBDoc, domain: string): T {
+  const schema = getSchema(domain)
   const { workspaceId, data, ...rest } = doc
   for (const key in rest) {
     if ((rest as any)[key] === 'NULL' || (rest as any)[key] === null) {
@@ -438,7 +444,7 @@ export function parseDoc<T extends Doc> (doc: DBDoc): T {
         ;(rest as any)[key] = null
       }
     }
-    if (key === 'modifiedOn' || key === 'createdOn') {
+    if (schema[key] !== undefined && schema[key].type === 'bigint') {
       ;(rest as any)[key] = Number.parseInt((rest as any)[key])
     }
   }