Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place. Commercial Alternative to JupyterHub.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place. Commercial Alternative to JupyterHub.
Path: blob/master/src/packages/util/db-schema/messages.ts
Views: 791
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6This is a table to support a simple messages system in cocalc, to support sending and replying to7messages between these three classes of entities:89- cocalc system10- projects11- users1213A message has a subject and body.1415When it is read can be set, and a message can also be saved for later.1617That's it! This is meant to be just enough to support things liked:1819- the system sending messages to users, e.g., reminds about notification20- a user replying and sending a message to the system (which admins could see).21- users sending messages to each other (and replying)22- users send message to system23- system send message to user24- project sending messages to users (e.g., about something happening)2526For simplicity there are no tags or any extra metadata -- put that in the markdown27in the body of the message.2829On purpose, all messages are sent/received in one central place in the UI, NOT associated30to particular files/directories/projects. Again, use links in the message for that.31*/3233import { Table } from "./types";34import { ID } from "./crm";35import throttle from "@cocalc/util/api/throttle";36import { SCHEMA } from "./index";37import { isEqual } from "lodash";38import { isValidUUID } from "@cocalc/util/misc";3940// make this a bit big initially -- we'll add a feature to "load more", hopefully before41// this limit is a problem42export const NUM_MESSAGES = 1000;4344interface Message0 {45id: number;46from_id: string; // a uuid47to_ids: string[]; // array of uuid's48subject: string;49body: string;50// sent is only set after message is sent.51sent?: Date;52// used for replies53thread_id?: number;5455// optionally included mainly in frontend code when working with a list of messages56// not in database!57index?: number;58}5960export const NON_BITSET_FIELDS = [61"id",62"subject",63"body",64"from_id",65"to_ids",66"sent",67"thread_id",68];6970export interface Message extends Message0 {71read?: string;72saved?: string;73starred?: string;74deleted?: string;75expire?: string;76}7778export interface MessageMe extends Message0 {79read?: boolean;80saved?: boolean;81starred?: boolean;82liked?: boolean;83deleted?: boolean;84expire?: boolean;85}8687export const MAX_LIMIT = 500;8889export const BITSET_FIELDS = [90"read",91"saved",92"starred",93"liked",94"deleted",95"expire",96] as const;9798export type BitSetField = (typeof BITSET_FIELDS)[number];99100export function isBitSetField(x): x is BitSetField {101return typeof x == "string" && BITSET_FIELDS.includes(x as any);102}103104export interface ApiMessagesGet {105account_id: string;106limit?: number;107offset?: number;108// received = all messages to you (the default)109// sent = all messages you sent110// new = to you and not read or saved -- these are what the counter counts111type?: "received" | "sent" | "new" | "starred" | "liked";112// strictly newer than cutoff113cutoff?: Date;114}115116export function getBitPosition({117account_id,118to_ids,119from_id,120}: {121account_id: string;122to_ids;123from_id: string;124}): number {125const i = to_ids.indexOf(account_id);126if (i != -1) {127return i + 1;128}129if (account_id == from_id) {130return 0;131}132throw Error(133`invalid user -- ${account_id}, to_ids=${JSON.stringify(to_ids)}, from_id=${from_id}`,134);135}136137Table({138name: "messages",139fields: {140id: ID,141sent: {142type: "timestamp",143desc: "When this message was actually sent. A draft is a message where sent has not yet been set.",144},145from_id: {146type: "uuid",147desc: "A project_id when from='project' and an account_id when from='account'. For type='system', haven't decided what this is yet (maybe some hardcoded uuid's for different components of the system?).",148not_null: true,149render: { type: "account" },150},151to_ids: {152type: "array",153pg_type: "UUID[]",154desc: "array of uuid's of account that the message is being sent to",155not_null: true,156render: { type: "accounts" },157},158subject: {159type: "string",160desc: "Subject of the message.",161not_null: true,162},163body: {164type: "string",165desc: "Body of the message (should be formatted as markdown).",166not_null: true,167},168thread_id: {169type: "number",170desc: "If this message is in a thread, this is the id of the root message.",171},172// The rest are status bitsets, with bit 0 corresponds to from_id, and bits 1 to n corresponding173// the users receiving the message, according to the ids in to_ids.174read: {175type: "string",176pg_type: "bit varying",177desc: "User read this message.",178},179saved: {180type: "string",181pg_type: "bit varying",182desc: "Users that saved this message for later (so no longer in inbox)",183},184starred: {185type: "string",186pg_type: "bit varying",187desc: "Users that starred this message so they can easily find it later",188},189liked: {190type: "string",191pg_type: "bit varying",192desc: "Users liked this message, indicate to sender and other uses that is good. Thumbs up.",193},194deleted: {195type: "string",196pg_type: "bit varying",197desc: "If user deleted this message (so in the trash).",198},199expire: {200type: "string",201pg_type: "bit varying",202desc: "User permanently deleted this message. ",203},204},205rules: {206primary_key: "id",207changefeed_keys: ["to_ids", "sent"],208pg_indexes: ["USING GIN (to_ids)", "sent"],209user_query: {210get: {211pg_where: [212{ "$::UUID = ANY(to_ids)": "account_id" },213"sent IS NOT null",214],215options: [{ order_by: "-id" }, { limit: NUM_MESSAGES }],216fields: {217id: null,218sent: null,219from_id: null,220to_ids: null,221subject: null,222body: null,223thread_id: null,224read: null,225saved: null,226starred: null,227liked: null,228deleted: null,229expire: null,230},231},232set: {233fields: {234id: true,235read: true,236saved: true,237starred: true,238liked: true,239deleted: true,240expire: true,241},242async instead_of_change(243database,244old_val,245new_val,246account_id,247cb,248): Promise<void> {249const client = database._client();250if (client == null) {251cb("database not connected -- try again later");252return;253}254if (old_val != null) {255// const dbg = database._dbg("messages:instead_of_change");256257// It took me a long time to figure out that this is the way to flip bits without changing what is there, which258// we need to do in order avoid a race condition, where two users say both mark a message read at almost the259// same time, and they both write out 01 and 10 for the read bitset... with last write wins, the database would260// end up with either 01 or 10, and one person's value is lost. That's sill. With just directly changing *only*261// the user's bit, we always end up with 11. And this code illustrates how to change one bit. Here "20" is262// the number of users (so number of recipients + 1), and 3 is the position to flip (+1 since it is 1-indexed in postgres),263// and it's `'x'::bit(1),3+1` to set the bit to x (=0 or 1), i.e., 0 in this example:264//265// smc=# update messages set saved=overlay(coalesce(saved,'0'::bit(1))::bit(20) PLACING '0'::bit(1) FROM 3+1) where id=61; select saved from messages where id=61;266267const ids = new_val.to_ids ?? old_val.to_ids ?? [];268const numUsers = ids.length + 1;269let userIndex = -1;270const setBit = (field: BitSetField, value: string) => {271if (userIndex == -1) {272// compute it first time, if needed273const n = ids.indexOf(account_id);274if (n == -1) {275throw Error(276"you do not have permission to edit this message",277);278}279userIndex = n + 1; // +1 to account for from_id280}281// ignore everything in value except the userIndex position.282const bit = value[userIndex] ?? "0";283if (bit != "0" && bit != "1") {284// be especially careful to avoid sql injection attack.285throw Error(`invalid bit '${bit}'`);286}287return `${field} = overlay(coalesce(${field},'0'::bit(1))::bit(${numUsers}) PLACING '${bit}'::bit(1) FROM ${userIndex}+1)`;288};289290const v: string[] = [];291for (const field of BITSET_FIELDS) {292if (new_val[field] != null && new_val[field] != old_val[field]) {293v.push(setBit(field, new_val[field]));294}295}296297if (v.length == 0) {298// nothing changed299cb();300return;301}302303try {304const query = `UPDATE messages SET ${v.join(",")} WHERE $1=ANY(to_ids) AND id=$2`;305const params = [account_id, parseInt(old_val.id)];306await client.query(query, params);307await database.updateUnreadMessageCount({ account_id });308cb();309} catch (err) {310cb(`${err}`);311}312} else {313cb(`use the sent_messages table to create a new message`);314}315},316},317},318},319});320321Table({322// this should be called "messages_from_me" because it also includes drafts that have not been sent yet323name: "sent_messages",324fields: SCHEMA.messages.fields,325rules: {326primary_key: SCHEMA.messages.primary_key,327changefeed_keys: ["from_id"],328virtual: "messages",329user_query: {330get: {331...SCHEMA.messages.user_query?.get!,332pg_where: [{ "from_id = $::UUID": "account_id" }],333},334set: {335fields: {336id: true,337to_ids: true,338subject: true,339body: true,340sent: true,341thread_id: true,342saved: true,343starred: true,344liked: true,345read: true,346deleted: true,347expire: true,348},349async instead_of_change(350database,351old_val,352new_val,353account_id,354cb,355): Promise<void> {356const client = database._client();357if (client == null) {358cb("database not connected -- try again later");359return;360}361if (old_val != null) {362try {363if (old_val.sent) {364// once a message is sent, the ONLY thing you can change are BITSET_FIELDS.365for (const field in new_val) {366// @ts-ignore367if (!BITSET_FIELDS.includes(field)) {368delete new_val[field];369}370}371// TODO: we might later have a notion of editing messages after they are sent, but this will372// be by adding one or more patches, so the edit history is clear.373}374if (375new_val.to_ids != null &&376!isEqual(new_val.to_ids, old_val.to_ids)377) {378await assertToIdsAreValid({ client, to_ids: new_val.to_ids });379}380381const setBit = (field: BitSetField, value: string) => {382const numUsers =3831 + (new_val.to_ids ?? old_val.to_ids ?? []).length;384const bit = value[0] ?? "0";385if (bit != "0" && bit != "1") {386throw Error(`invalid bit '${bit}'`);387}388return `${field} = overlay(coalesce(${field},'0'::bit(1))::bit(${numUsers}) PLACING '${bit}'::bit(1) FROM 1)`;389};390const v: string[] = [];391for (const field of BITSET_FIELDS) {392if (393new_val[field] != null &&394new_val[field] != old_val[field]395) {396v.push(setBit(field, new_val[field]));397}398}399const bitsets = v.length == 0 ? "" : "," + v.join(",");400401// user is allowed to change a lot about messages *from* them only.402// putting from_id in the query specifically as an extra security measure, so user can't change403// message with id they don't own.404const query = `UPDATE messages SET to_ids=$3,subject=$4,body=$5,sent=$6,thread_id=$7 ${bitsets} WHERE from_id=$1 AND id=$2`;405const params = [406account_id,407parseInt(old_val.id),408new_val.to_ids ?? old_val.to_ids,409new_val.subject ?? old_val.subject,410new_val.body ?? old_val.body,411new_val.sent ?? old_val.sent,412new_val.thread_id ?? old_val.thread_id,413];414await client.query(query, params);415const to_ids = new_val.to_ids ?? old_val.to_ids;416if (to_ids && (new_val.sent ?? old_val.sent)) {417for (const account_id of to_ids) {418await database.updateUnreadMessageCount({419account_id,420});421}422}423cb();424} catch (err) {425cb(`${err}`);426}427} else {428// create a new message:429cb("use the create_message virtual table to create messages");430}431},432},433},434},435});436437async function assertToIdsAreValid({ client, to_ids }) {438const { rows } = await client.query(439"SELECT account_id FROM accounts WHERE account_id=ANY($1)",440[to_ids],441);442if (rows.length != to_ids.length) {443const exist = new Set(rows.map(({ account_id }) => account_id));444const missing = to_ids.filter((account_id) => !exist.has(account_id));445if (missing.length > 0) {446throw Error(447`every target account_id must exist -- these accounts do not exist: ${JSON.stringify(missing)}`,448);449}450}451}452453// See comment in groups -- for create_groups.454Table({455name: "create_message",456rules: {457virtual: "messages",458primary_key: "id",459user_query: {460get: {461fields: {462id: null,463to_ids: null,464subject: null,465body: null,466sent: null,467thread_id: null,468},469async instead_of_query(database, opts, cb): Promise<void> {470try {471const { account_id } = opts;472throttle({473endpoint: "user_query-create_message",474account_id,475});476const client = database._client();477const query = opts.query ?? {};478const to_ids = Array.from(new Set(query.to_ids));479await assertToIdsAreValid({ client, to_ids });480const { rows } = await client.query(481`INSERT INTO messages(from_id,to_ids,subject,body,thread_id,sent)482VALUES($1::UUID,$2::UUID[],$3,$4,$5,$6) RETURNING *483`,484[485account_id,486to_ids,487opts.query.subject,488opts.query.body,489opts.query.thread_id,490opts.query.sent,491],492);493if (opts.query.sent) {494for (const account_id of to_ids) {495await database.updateUnreadMessageCount({496account_id,497});498}499}500cb(undefined, rows[0]);501} catch (err) {502cb(`${err}`);503}504},505},506},507},508fields: SCHEMA.groups.fields,509});510511Table({512name: "crm_messages",513rules: {514virtual: "messages",515primary_key: "id",516user_query: {517get: {518admin: true, // only admins can do get queries on this table519fields: SCHEMA.messages.user_query?.get?.fields ?? {},520},521},522},523fields: SCHEMA.messages.fields,524});525526// Helper function for database queries.527528export function pgBitField(529field: BitSetField,530account_id: string,531as?: string,532) {533// be extra careful due to possibility of SQL injection.534if (!isBitSetField(field)) {535throw Error(`field ${field} must be a bitset field`);536}537if (!isValidUUID(account_id)) {538throw Error("account_id must be valid");539}540if (as == null) {541as = ` AS ${field}`;542} else if (as) {543as = ` AS ${as}`;544}545return `coalesce(substring(${field},array_position(to_ids,'${account_id}')+1,1),'0'::bit(1)) = '1'::bit(1) ${as}`;546}547548export function pgBitFieldSelf(field: BitSetField, as?: string) {549// be extra careful due to possibility of SQL injection.550if (!isBitSetField(field)) {551throw Error(`field ${field} must be a bitset field`);552}553if (as == null) {554as = ` AS ${field}`;555} else if (as) {556as = ` AS ${as}`;557}558return `coalesce(substring(${field},1,1),'0'::bit(1)) = '1'::bit(1) ${as}`;559}560561562