diff --git a/drizzle/pg/0003_lush_winter_soldier.sql b/drizzle/pg/0003_lush_winter_soldier.sql new file mode 100644 index 00000000..d46759bd --- /dev/null +++ b/drizzle/pg/0003_lush_winter_soldier.sql @@ -0,0 +1,13 @@ +CREATE TABLE "channel_metadata" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "node_id" uuid NOT NULL, + "channel_id" text NOT NULL, + "note" text NOT NULL, + "created_at" timestamp(6) DEFAULT now() NOT NULL, + "updated_at" timestamp(6) DEFAULT now() NOT NULL, + CONSTRAINT "channel_metadata_user_id_node_id_channel_id_unique" UNIQUE("user_id","node_id","channel_id") +); +--> statement-breakpoint +ALTER TABLE "channel_metadata" ADD CONSTRAINT "channel_metadata_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "channel_metadata" ADD CONSTRAINT "channel_metadata_node_id_nodes_id_fk" FOREIGN KEY ("node_id") REFERENCES "public"."nodes"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/pg/meta/0003_snapshot.json b/drizzle/pg/meta/0003_snapshot.json new file mode 100644 index 00000000..1895ce29 --- /dev/null +++ b/drizzle/pg/meta/0003_snapshot.json @@ -0,0 +1,413 @@ +{ + "id": "9d1326c3-8439-43b5-aa48-3b341750cb68", + "prevId": "6e99aaac-c7ae-4c97-a3ca-12073d7da436", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.channel_metadata": { + "name": "channel_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "channel_metadata_user_id_users_id_fk": { + "name": "channel_metadata_user_id_users_id_fk", + "tableFrom": "channel_metadata", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_metadata_node_id_nodes_id_fk": { + "name": "channel_metadata_node_id_nodes_id_fk", + "tableFrom": "channel_metadata", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "channel_metadata_user_id_node_id_channel_id_unique": { + "name": "channel_metadata_user_id_node_id_channel_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "node_id", + "channel_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nodes": { + "name": "nodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "socket": { + "name": "socket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_macaroon": { + "name": "encrypted_macaroon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_cert": { + "name": "encrypted_cert", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_amboss_jwt": { + "name": "encrypted_amboss_jwt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "nodes_team_id_teams_id_fk": { + "name": "nodes_team_id_teams_id_fk", + "tableFrom": "nodes", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_nodes": { + "name": "user_nodes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_nodes_user_id_users_id_fk": { + "name": "user_nodes_user_id_users_id_fk", + "tableFrom": "user_nodes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_nodes_node_id_nodes_id_fk": { + "name": "user_nodes_node_id_nodes_id_fk", + "tableFrom": "user_nodes", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_nodes_user_id_node_id_unique": { + "name": "user_nodes_user_id_node_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "node_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp(6)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "users_team_id_teams_id_fk": { + "name": "users_team_id_teams_id_fk", + "tableFrom": "users", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/pg/meta/_journal.json b/drizzle/pg/meta/_journal.json index 5ab22297..5504722e 100644 --- a/drizzle/pg/meta/_journal.json +++ b/drizzle/pg/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1776104485702, "tag": "0002_add_encrypted_amboss_jwt", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1777519591448, + "tag": "0003_lush_winter_soldier", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0003_orange_captain_universe.sql b/drizzle/sqlite/0003_orange_captain_universe.sql new file mode 100644 index 00000000..2ec04eca --- /dev/null +++ b/drizzle/sqlite/0003_orange_captain_universe.sql @@ -0,0 +1,13 @@ +CREATE TABLE `channel_metadata` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `node_id` text NOT NULL, + `channel_id` text NOT NULL, + `note` text NOT NULL, + `created_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + `updated_at` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`node_id`) REFERENCES `nodes`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `channel_metadata_user_id_node_id_channel_id_unique` ON `channel_metadata` (`user_id`,`node_id`,`channel_id`); \ No newline at end of file diff --git a/drizzle/sqlite/meta/0003_snapshot.json b/drizzle/sqlite/meta/0003_snapshot.json new file mode 100644 index 00000000..eabf8e10 --- /dev/null +++ b/drizzle/sqlite/meta/0003_snapshot.json @@ -0,0 +1,426 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "62dfa64c-8696-4c03-9c25-2921fff5fd5f", + "prevId": "2b0142c3-134e-4c4d-b5ef-e7aae8280038", + "tables": { + "channel_metadata": { + "name": "channel_metadata", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "channel_metadata_user_id_node_id_channel_id_unique": { + "name": "channel_metadata_user_id_node_id_channel_id_unique", + "columns": [ + "user_id", + "node_id", + "channel_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "channel_metadata_user_id_users_id_fk": { + "name": "channel_metadata_user_id_users_id_fk", + "tableFrom": "channel_metadata", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "channel_metadata_node_id_nodes_id_fk": { + "name": "channel_metadata_node_id_nodes_id_fk", + "tableFrom": "channel_metadata", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "nodes": { + "name": "nodes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "network": { + "name": "network", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "socket": { + "name": "socket", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_macaroon": { + "name": "encrypted_macaroon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_cert": { + "name": "encrypted_cert", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_amboss_jwt": { + "name": "encrypted_amboss_jwt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "nodes_team_id_teams_id_fk": { + "name": "nodes_team_id_teams_id_fk", + "tableFrom": "nodes", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "teams": { + "name": "teams", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_nodes": { + "name": "user_nodes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "user_nodes_user_id_node_id_unique": { + "name": "user_nodes_user_id_node_id_unique", + "columns": [ + "user_id", + "node_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "user_nodes_user_id_users_id_fk": { + "name": "user_nodes_user_id_users_id_fk", + "tableFrom": "user_nodes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "user_nodes_node_id_nodes_id_fk": { + "name": "user_nodes_node_id_nodes_id_fk", + "tableFrom": "user_nodes", + "tableTo": "nodes", + "columnsFrom": [ + "node_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": { + "users_team_id_teams_id_fk": { + "name": "users_team_id_teams_id_fk", + "tableFrom": "users", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index d806a43b..34f8fea0 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1776104481313, "tag": "0002_add_encrypted_amboss_jwt", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1777519591063, + "tag": "0003_orange_captain_universe", + "breakpoints": true } ] } \ No newline at end of file diff --git a/schema.gql b/schema.gql index 20499e23..42fc82ee 100644 --- a/schema.gql +++ b/schema.gql @@ -160,6 +160,7 @@ type Channel { is_private: Boolean! local_balance: Float! local_reserve: Float! + note: String partner_fee_info: SingleChannel partner_node_info: Node! partner_public_key: String! @@ -193,6 +194,12 @@ type ChannelInfo { node2_info: BaseNodeInfo! } +type ChannelMetadata { + channel_id: String! + note: String! + updated_at: String! +} + type ChannelReport { commit: Float! incomingPendingHtlc: Float! @@ -219,6 +226,11 @@ type ChannelSummary { total_remote_sats: String! } +type ChannelsMutations { + delete_note(channelId: String!): Boolean! + upsert_note(channelId: String!, note: String!): ChannelMetadata! +} + type ClosedChannel { capacity: Float! channel_age: Float @@ -569,6 +581,7 @@ type Mutation { updateFees(base_fee_tokens: Float, cltv_delta: Float, fee_rate: Float, max_htlc_mtokens: String, min_htlc_mtokens: String, transaction_id: String, transaction_vout: Float): Boolean! updateMultipleFees(channels: [UpdateRoutingFeesParams!]!): Boolean! updateTwofaSecret(secret: String!, token: String!): Boolean! + user: UserMutations! } type NetworkInfo { @@ -660,6 +673,10 @@ type NodeType { public_key: String! } +type OffchainMutations { + channels: ChannelsMutations! +} + input OfferReadinessInput { peer_pubkey: String! tapd_asset_id: String @@ -1340,6 +1357,10 @@ type UserBackupInfo { total_size_saved: String! } +type UserMutations { + offchain: OffchainMutations! +} + type UserNode { id: String! name: String! diff --git a/src/client/src/graphql/mutations/__generated__/setChannelNote.generated.tsx b/src/client/src/graphql/mutations/__generated__/setChannelNote.generated.tsx new file mode 100644 index 00000000..c8e56042 --- /dev/null +++ b/src/client/src/graphql/mutations/__generated__/setChannelNote.generated.tsx @@ -0,0 +1,157 @@ +import * as Types from '../../types'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type UpsertChannelNoteMutationVariables = Types.Exact<{ + channelId: Types.Scalars['String']['input']; + note: Types.Scalars['String']['input']; +}>; + +export type UpsertChannelNoteMutation = { + __typename?: 'Mutation'; + user: { + __typename?: 'UserMutations'; + offchain: { + __typename?: 'OffchainMutations'; + channels: { + __typename?: 'ChannelsMutations'; + upsert_note: { + __typename?: 'ChannelMetadata'; + channel_id: string; + note: string; + updated_at: string; + }; + }; + }; + }; +}; + +export type DeleteChannelNoteMutationVariables = Types.Exact<{ + channelId: Types.Scalars['String']['input']; +}>; + +export type DeleteChannelNoteMutation = { + __typename?: 'Mutation'; + user: { + __typename?: 'UserMutations'; + offchain: { + __typename?: 'OffchainMutations'; + channels: { __typename?: 'ChannelsMutations'; delete_note: boolean }; + }; + }; +}; + +export const UpsertChannelNoteDocument = gql` + mutation UpsertChannelNote($channelId: String!, $note: String!) { + user { + offchain { + channels { + upsert_note(channelId: $channelId, note: $note) { + channel_id + note + updated_at + } + } + } + } + } +`; +export type UpsertChannelNoteMutationFn = Apollo.MutationFunction< + UpsertChannelNoteMutation, + UpsertChannelNoteMutationVariables +>; + +/** + * __useUpsertChannelNoteMutation__ + * + * To run a mutation, you first call `useUpsertChannelNoteMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpsertChannelNoteMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [upsertChannelNoteMutation, { data, loading, error }] = useUpsertChannelNoteMutation({ + * variables: { + * channelId: // value for 'channelId' + * note: // value for 'note' + * }, + * }); + */ +export function useUpsertChannelNoteMutation( + baseOptions?: Apollo.MutationHookOptions< + UpsertChannelNoteMutation, + UpsertChannelNoteMutationVariables + > +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + UpsertChannelNoteMutation, + UpsertChannelNoteMutationVariables + >(UpsertChannelNoteDocument, options); +} +export type UpsertChannelNoteMutationHookResult = ReturnType< + typeof useUpsertChannelNoteMutation +>; +export type UpsertChannelNoteMutationResult = + Apollo.MutationResult; +export type UpsertChannelNoteMutationOptions = Apollo.BaseMutationOptions< + UpsertChannelNoteMutation, + UpsertChannelNoteMutationVariables +>; +export const DeleteChannelNoteDocument = gql` + mutation DeleteChannelNote($channelId: String!) { + user { + offchain { + channels { + delete_note(channelId: $channelId) + } + } + } + } +`; +export type DeleteChannelNoteMutationFn = Apollo.MutationFunction< + DeleteChannelNoteMutation, + DeleteChannelNoteMutationVariables +>; + +/** + * __useDeleteChannelNoteMutation__ + * + * To run a mutation, you first call `useDeleteChannelNoteMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteChannelNoteMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteChannelNoteMutation, { data, loading, error }] = useDeleteChannelNoteMutation({ + * variables: { + * channelId: // value for 'channelId' + * }, + * }); + */ +export function useDeleteChannelNoteMutation( + baseOptions?: Apollo.MutationHookOptions< + DeleteChannelNoteMutation, + DeleteChannelNoteMutationVariables + > +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useMutation< + DeleteChannelNoteMutation, + DeleteChannelNoteMutationVariables + >(DeleteChannelNoteDocument, options); +} +export type DeleteChannelNoteMutationHookResult = ReturnType< + typeof useDeleteChannelNoteMutation +>; +export type DeleteChannelNoteMutationResult = + Apollo.MutationResult; +export type DeleteChannelNoteMutationOptions = Apollo.BaseMutationOptions< + DeleteChannelNoteMutation, + DeleteChannelNoteMutationVariables +>; diff --git a/src/client/src/graphql/mutations/setChannelNote.ts b/src/client/src/graphql/mutations/setChannelNote.ts new file mode 100644 index 00000000..e1ef295e --- /dev/null +++ b/src/client/src/graphql/mutations/setChannelNote.ts @@ -0,0 +1,29 @@ +import { gql } from '@apollo/client'; + +export const UPSERT_CHANNEL_NOTE = gql` + mutation UpsertChannelNote($channelId: String!, $note: String!) { + user { + offchain { + channels { + upsert_note(channelId: $channelId, note: $note) { + channel_id + note + updated_at + } + } + } + } + } +`; + +export const DELETE_CHANNEL_NOTE = gql` + mutation DeleteChannelNote($channelId: String!) { + user { + offchain { + channels { + delete_note(channelId: $channelId) + } + } + } + } +`; diff --git a/src/client/src/graphql/queries/__generated__/getChannels.generated.tsx b/src/client/src/graphql/queries/__generated__/getChannels.generated.tsx index a2afbffa..5a105faa 100644 --- a/src/client/src/graphql/queries/__generated__/getChannels.generated.tsx +++ b/src/client/src/graphql/queries/__generated__/getChannels.generated.tsx @@ -34,6 +34,7 @@ export type GetChannelsQuery = { transaction_vout: number; unsettled_balance: number; channel_age: number; + note?: string | null; pending_resume: { __typename?: 'PendingResume'; incoming_tokens: number; @@ -123,6 +124,7 @@ export const GetChannelsDocument = gql` transaction_vout unsettled_balance channel_age + note pending_resume { incoming_tokens outgoing_tokens diff --git a/src/client/src/graphql/queries/getChannels.ts b/src/client/src/graphql/queries/getChannels.ts index 0ea739da..f557d272 100644 --- a/src/client/src/graphql/queries/getChannels.ts +++ b/src/client/src/graphql/queries/getChannels.ts @@ -26,6 +26,7 @@ export const GET_CHANNELS = gql` transaction_vout unsettled_balance channel_age + note pending_resume { incoming_tokens outgoing_tokens diff --git a/src/client/src/graphql/types.ts b/src/client/src/graphql/types.ts index 941c2b70..4d59a4b8 100644 --- a/src/client/src/graphql/types.ts +++ b/src/client/src/graphql/types.ts @@ -202,6 +202,7 @@ export type Channel = { is_private: Scalars['Boolean']['output']; local_balance: Scalars['Float']['output']; local_reserve: Scalars['Float']['output']; + note?: Maybe; partner_fee_info?: Maybe; partner_node_info: Node; partner_public_key: Scalars['String']['output']; @@ -237,6 +238,13 @@ export type ChannelInfo = { node2_info: BaseNodeInfo; }; +export type ChannelMetadata = { + __typename?: 'ChannelMetadata'; + channel_id: Scalars['String']['output']; + note: Scalars['String']['output']; + updated_at: Scalars['String']['output']; +}; + export type ChannelReport = { __typename?: 'ChannelReport'; commit: Scalars['Float']['output']; @@ -266,6 +274,21 @@ export type ChannelSummary = { total_remote_sats: Scalars['String']['output']; }; +export type ChannelsMutations = { + __typename?: 'ChannelsMutations'; + delete_note: Scalars['Boolean']['output']; + upsert_note: ChannelMetadata; +}; + +export type ChannelsMutationsDelete_NoteArgs = { + channelId: Scalars['String']['input']; +}; + +export type ChannelsMutationsUpsert_NoteArgs = { + channelId: Scalars['String']['input']; + note: Scalars['String']['input']; +}; + export type ClosedChannel = { __typename?: 'ClosedChannel'; capacity: Scalars['Float']['output']; @@ -662,6 +685,7 @@ export type Mutation = { updateFees: Scalars['Boolean']['output']; updateMultipleFees: Scalars['Boolean']['output']; updateTwofaSecret: Scalars['Boolean']['output']; + user: UserMutations; }; export type MutationAddPeerArgs = { @@ -904,6 +928,11 @@ export type NodeType = { public_key: Scalars['String']['output']; }; +export type OffchainMutations = { + __typename?: 'OffchainMutations'; + channels: ChannelsMutations; +}; + export type OfferReadinessInput = { peer_pubkey: Scalars['String']['input']; tapd_asset_id?: InputMaybe; @@ -916,8 +945,8 @@ export type OfferReadinessResult = { btc_channels: ChannelSummary; has_pending_order: Scalars['Boolean']['output']; is_peer_connected: Scalars['Boolean']['output']; - onchain_balance_sats: Scalars['String']['output']; onchain_asset_balance: Scalars['String']['output']; + onchain_balance_sats: Scalars['String']['output']; }; export type OnChainBalance = { @@ -1807,6 +1836,11 @@ export type UserBackupInfo = { total_size_saved: Scalars['String']['output']; }; +export type UserMutations = { + __typename?: 'UserMutations'; + offchain: OffchainMutations; +}; + export type UserNode = { __typename?: 'UserNode'; id: Scalars['String']['output']; diff --git a/src/client/src/views/channels/channels/ChannelDetails.tsx b/src/client/src/views/channels/channels/ChannelDetails.tsx index 5821b537..5a821d5b 100644 --- a/src/client/src/views/channels/channels/ChannelDetails.tsx +++ b/src/client/src/views/channels/channels/ChannelDetails.tsx @@ -3,10 +3,10 @@ import { Loader2 } from 'lucide-react'; import { ChangeDetails } from '../../../components/modal/changeDetails/ChangeDetails'; import { useGetChannelInfoQuery } from '../../../graphql/queries/__generated__/getChannel.generated'; -export const ChannelDetails: FC<{ id?: string; name?: string }> = ({ - id = '', - name = '', -}) => { +export const ChannelDetails: FC<{ + id?: string; + name?: string; +}> = ({ id = '', name = '' }) => { const { data, loading, error } = useGetChannelInfoQuery({ variables: { id }, skip: !id, diff --git a/src/client/src/views/channels/channels/ChannelTable.tsx b/src/client/src/views/channels/channels/ChannelTable.tsx index ee70aa36..c44e5e45 100644 --- a/src/client/src/views/channels/channels/ChannelTable.tsx +++ b/src/client/src/views/channels/channels/ChannelTable.tsx @@ -1,6 +1,28 @@ import { useCallback, useMemo, useState } from 'react'; -import { ArrowDown, ArrowUp, Check, Circle, Edit, X } from 'lucide-react'; +import { + ArrowDown, + ArrowUp, + Check, + Circle, + Edit, + Trash2, + X, +} from 'lucide-react'; import toast from 'react-hot-toast'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { BalanceBars } from '../../../components/balance'; import { getChannelLink, @@ -12,6 +34,10 @@ import Modal from '../../../components/modal/ReactModal'; import { Price } from '../../../components/price/Price'; import Table from '../../../components/table'; import { useGetChannelsQuery } from '../../../graphql/queries/__generated__/getChannels.generated'; +import { + useUpsertChannelNoteMutation, + useDeleteChannelNoteMutation, +} from '../../../graphql/mutations/__generated__/setChannelNote.generated'; import { useLocalStorage } from '../../../hooks/UseLocalStorage'; import { useChartColors } from '../../../lib/chart-colors'; import { getErrorContent } from '../../../utils/error'; @@ -22,6 +48,7 @@ import { getPercent, } from '../../../utils/helpers'; import { colorFromString } from '../../../utils/color'; +import { useAccount } from '../../../hooks/UseAccount'; import { ChannelDetails } from './ChannelDetails'; import { defaultHiddenColumns } from './helpers'; import { VisibilityState } from '@tanstack/react-table'; @@ -33,11 +60,155 @@ const getBar = (top: number, bottom: number) => { const REMOTE_COLOR = 'rgba(209, 213, 219, 0.6)'; +// ── NoteCell ────────────────────────────────────────────────────────────────── + +type NoteCellProps = { + note: string; + channelId: string; + isDbAccount: boolean; +}; + +const NoteCell = ({ note, channelId, isDbAccount }: NoteCellProps) => { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(note); + const [upsertNote, { loading: saving }] = useUpsertChannelNoteMutation(); + const [deleteNote, { loading: deleting }] = useDeleteChannelNoteMutation(); + + const updateCache = (newNote: string) => (cache: any) => { + cache.modify({ + id: cache.identify({ __typename: 'Channel', id: channelId }), + fields: { note: () => newNote }, + }); + }; + + const handleOpen = () => { + setValue(note); + setOpen(true); + }; + + const handleSave = async () => { + try { + await upsertNote({ + variables: { channelId, note: value }, + update: updateCache(value), + }); + setOpen(false); + } catch { + toast.error('Failed to save note'); + } + }; + + const handleDelete = async () => { + try { + await deleteNote({ + variables: { channelId }, + update: updateCache(''), + }); + setOpen(false); + } catch { + toast.error('Failed to delete note'); + } + }; + + const truncated = note.length > 32 ? `${note.slice(0, 32)}...` : note; + + const trigger = ( + + ); + + return ( + <> + {note.length > 10 ? ( + + {trigger} + + {note} + + + ) : ( + trigger + )} + + e.stopPropagation()}> + + Channel Note + + {isDbAccount ? ( + <> + setValue(e.target.value)} + placeholder="Personal note for this channel..." + onKeyDown={e => { + if (e.key === 'Enter' && value.trim()) handleSave(); + }} + /> + + {note && ( + + )} + + + + ) : ( +

+ Channel notes require a database account.{' '} + + Learn how to set up a database. + +

+ )} +
+
+ + ); +}; + +// ── ChannelTable ────────────────────────────────────────────────────────────── + export const ChannelTable = ({ assetOnly, storageKey = 'hiddenColumns-v2', }: { assetOnly?: boolean; storageKey?: string } = {}) => { const chartColors = useChartColors(); + const account = useAccount(); + const isDbAccount = account?.type === 'db'; const [channel, setChannel] = useState<{ name: string; @@ -200,7 +371,6 @@ export const ChannelTable = ({ ), }; - // Build dynamic asset fields for each unique asset const assetFields: Record = {}; for (const [key, assetInfo] of uniqueAssets) { const channelAssetKey = c.asset @@ -258,6 +428,7 @@ export const ChannelTable = ({ ...partnerInfo, ...actions, ...assetFields, + note: c.note ?? '', alias: c.partner_node_info.node?.alias || 'Unknown', undercaseAlias: ( c.partner_node_info.node?.alias || 'Unknown' @@ -403,6 +574,18 @@ export const ChannelTable = ({ ), }, + { + header: 'Note', + accessorKey: 'note', + enableSorting: false, + cell: ({ row }: any) => ( + + ), + }, { header: 'Capacity', accessorKey: 'capacity', @@ -641,7 +824,7 @@ export const ChannelTable = ({ }; }), ], - [numberStringSorting, uniqueAssets] + [numberStringSorting, uniqueAssets, isDbAccount] ); const handleToggle = (hide: boolean, id: string) => { diff --git a/src/server/modules/api/channels/channel-metadata.service.ts b/src/server/modules/api/channels/channel-metadata.service.ts new file mode 100644 index 00000000..5054b7f6 --- /dev/null +++ b/src/server/modules/api/channels/channel-metadata.service.ts @@ -0,0 +1,90 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { and, eq } from 'drizzle-orm'; +import { DRIZZLE, DrizzleProvider } from '../../database/drizzle.provider'; +import { ChannelMetadata } from './channel-metadata.types'; + +@Injectable() +export class ChannelMetadataService { + constructor(@Inject(DRIZZLE) private readonly drizzle: DrizzleProvider) {} + + async getNotesByNode( + userId: string, + nodeId: string + ): Promise> { + if (!this.drizzle) return new Map(); + const { db, schema } = this.drizzle; + const rows = await (db as any) + .select({ + channel_id: schema.channelMetadata.channel_id, + note: schema.channelMetadata.note, + }) + .from(schema.channelMetadata) + .where( + and( + eq(schema.channelMetadata.user_id, userId), + eq(schema.channelMetadata.node_id, nodeId) + ) + ); + const map = new Map(); + for (const row of rows) { + map.set(row.channel_id, row.note); + } + return map; + } + + async upsertNote( + userId: string, + nodeId: string, + channelId: string, + note: string + ): Promise { + if (!this.drizzle) { + throw new Error( + 'Channel notes require a database. Set DB_TYPE and DB_SQLITE_PATH (or DB_POSTGRES_URL) in your environment.' + ); + } + const { db, schema } = this.drizzle; + const now = new Date().toISOString(); + await (db as any) + .insert(schema.channelMetadata) + .values({ + user_id: userId, + node_id: nodeId, + channel_id: channelId, + note, + updated_at: now, + }) + .onConflictDoUpdate({ + target: [ + schema.channelMetadata.user_id, + schema.channelMetadata.node_id, + schema.channelMetadata.channel_id, + ], + set: { note, updated_at: now }, + }); + return { channel_id: channelId, note, updated_at: now }; + } + + async deleteNote( + userId: string, + nodeId: string, + channelId: string + ): Promise { + if (!this.drizzle) { + throw new Error( + 'Channel notes require a database. Set DB_TYPE and DB_SQLITE_PATH (or DB_POSTGRES_URL) in your environment.' + ); + } + const { db, schema } = this.drizzle; + await (db as any) + .delete(schema.channelMetadata) + .where( + and( + eq(schema.channelMetadata.user_id, userId), + eq(schema.channelMetadata.node_id, nodeId), + eq(schema.channelMetadata.channel_id, channelId) + ) + ); + return true; + } +} diff --git a/src/server/modules/api/channels/channel-metadata.types.ts b/src/server/modules/api/channels/channel-metadata.types.ts new file mode 100644 index 00000000..5da205e7 --- /dev/null +++ b/src/server/modules/api/channels/channel-metadata.types.ts @@ -0,0 +1,34 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class ChannelMetadata { + @Field(() => String) + channel_id: string; + + @Field(() => String) + note: string; + + @Field(() => String) + updated_at: string; +} + +@ObjectType() +export class ChannelsMutations { + @Field(() => ChannelMetadata) + upsert_note: ChannelMetadata; + + @Field(() => Boolean) + delete_note: boolean; +} + +@ObjectType() +export class OffchainMutations { + @Field(() => ChannelsMutations) + channels: ChannelsMutations; +} + +@ObjectType() +export class UserMutations { + @Field(() => OffchainMutations) + offchain: OffchainMutations; +} diff --git a/src/server/modules/api/channels/channel.resolver.ts b/src/server/modules/api/channels/channel.resolver.ts index 66ba0e50..1c8fb10e 100644 --- a/src/server/modules/api/channels/channel.resolver.ts +++ b/src/server/modules/api/channels/channel.resolver.ts @@ -1,11 +1,12 @@ import { Inject } from '@nestjs/common'; -import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { Context, Parent, ResolveField, Resolver } from '@nestjs/graphql'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { toWithError } from 'src/server/utils/async'; import { Logger } from 'winston'; +import { ContextType } from 'src/server/app.module'; import { NodeService } from '../../node/node.service'; import { CurrentUser } from '../../security/security.decorators'; -import { UserId } from '../../security/security.types'; +import { AuthType, UserId } from '../../security/security.types'; import { Channel, SingleChannelParentType } from './channels.types'; @Resolver(Channel) @@ -101,4 +102,20 @@ export class ChannelResolver { partner_node_policies, }; } + + @ResolveField(() => String, { nullable: true }) + async note( + @CurrentUser() user: UserId, + @Parent() { id }: Channel, + @Context() { loaders }: ContextType + ): Promise { + if (user.authType !== AuthType.USER) return null; + const dbUserId = user.userId ?? user.id; + const nodeId = user.id; + return loaders.channelNotesLoader.load({ + userId: dbUserId, + nodeId, + channelId: id, + }); + } } diff --git a/src/server/modules/api/channels/channels.module.ts b/src/server/modules/api/channels/channels.module.ts index c3b4bb84..521ba590 100644 --- a/src/server/modules/api/channels/channels.module.ts +++ b/src/server/modules/api/channels/channels.module.ts @@ -1,13 +1,28 @@ import { Module } from '@nestjs/common'; import { NodeModule } from '../../node/node.module'; import { ChannelResolver } from './channel.resolver'; -import { ChannelsResolver } from './channels.resolver'; +import { + ChannelsResolver, + UserMutationRoot, + UserMutationsResolver, + OffchainMutationsResolver, + ChannelsMutationsResolver, +} from './channels.resolver'; +import { ChannelMetadataService } from './channel-metadata.service'; import { FetchModule } from '../../fetch/fetch.module'; import { AmbossModule } from '../amboss/amboss.module'; import { TapdModule } from '../../node/tapd/tapd.module'; @Module({ imports: [NodeModule, FetchModule, AmbossModule, TapdModule], - providers: [ChannelsResolver, ChannelResolver], + providers: [ + ChannelsResolver, + ChannelResolver, + ChannelMetadataService, + UserMutationRoot, + UserMutationsResolver, + OffchainMutationsResolver, + ChannelsMutationsResolver, + ], }) export class ChannelsModule {} diff --git a/src/server/modules/api/channels/channels.resolver.ts b/src/server/modules/api/channels/channels.resolver.ts index 7156381e..0afd59c0 100644 --- a/src/server/modules/api/channels/channels.resolver.ts +++ b/src/server/modules/api/channels/channels.resolver.ts @@ -1,5 +1,5 @@ import { Inject } from '@nestjs/common'; -import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql'; import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; import { toWithError } from 'src/server/utils/async'; import { Logger } from 'winston'; @@ -23,6 +23,13 @@ import { FetchService } from '../../fetch/fetch.service'; import { GetRecommendedNode } from '../amboss/amboss.gql'; import { AmbossService } from '../amboss/amboss.service'; import { TapdNodeService } from '../../node/tapd/tapd-node.service'; +import { + ChannelMetadata, + ChannelsMutations, + OffchainMutations, + UserMutations, +} from './channel-metadata.types'; +import { ChannelMetadataService } from './channel-metadata.service'; function toAssetField(ac: { assetId: string; @@ -453,3 +460,70 @@ export class ChannelsResolver { return errors ? false : true; } } + +@Resolver() +export class UserMutationRoot { + @Mutation(() => UserMutations) + async user(): Promise { + return {} as any; + } +} + +@Resolver(() => UserMutations) +export class UserMutationsResolver { + @ResolveField(() => OffchainMutations) + async offchain(): Promise { + return {} as any; + } +} + +@Resolver(() => OffchainMutations) +export class OffchainMutationsResolver { + @ResolveField(() => ChannelsMutations) + async channels(): Promise { + return {} as any; + } +} + +@Resolver(() => ChannelsMutations) +export class ChannelsMutationsResolver { + constructor(private channelMetadataService: ChannelMetadataService) {} + + @ResolveField(() => ChannelMetadata) + async upsert_note( + @CurrentUser() user: UserId, + @Args('channelId') channelId: string, + @Args('note') note: string + ): Promise { + if (!/^\d+x\d+x\d+$/.test(channelId)) { + throw new GraphQLError('Invalid channel ID format.'); + } + if (!note.trim()) { + throw new GraphQLError('Note cannot be empty.'); + } + if (note.length > 500) { + throw new GraphQLError('Note must be 500 characters or fewer.'); + } + const dbUserId = user.userId ?? user.id; + const nodeId = user.id; + return this.channelMetadataService.upsertNote( + dbUserId, + nodeId, + channelId, + note + ); + } + + @ResolveField(() => Boolean) + async delete_note( + @CurrentUser() user: UserId, + @Args('channelId') channelId: string + ): Promise { + if (!/^\d+x\d+x\d+$/.test(channelId)) { + throw new GraphQLError('Invalid channel ID format.'); + } + const dbUserId = user.userId ?? user.id; + const nodeId = user.id; + return this.channelMetadataService.deleteNote(dbUserId, nodeId, channelId); + } +} diff --git a/src/server/modules/api/channels/channels.types.ts b/src/server/modules/api/channels/channels.types.ts index 84417022..cf4c205a 100644 --- a/src/server/modules/api/channels/channels.types.ts +++ b/src/server/modules/api/channels/channels.types.ts @@ -169,6 +169,8 @@ export class Channel { pending_resume: PendingResume; @Field(() => ChannelAsset, { nullable: true }) asset?: ChannelAsset; + @Field({ nullable: true }) + note?: string; } export type SingleChannelParentType = { diff --git a/src/server/modules/database/schema/pg/channel-metadata.ts b/src/server/modules/database/schema/pg/channel-metadata.ts new file mode 100644 index 00000000..2e173c38 --- /dev/null +++ b/src/server/modules/database/schema/pg/channel-metadata.ts @@ -0,0 +1,28 @@ +import { pgTable, uuid, text, timestamp, unique } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { users } from './users'; +import { nodes } from './nodes'; + +export const channelMetadata = pgTable( + 'channel_metadata', + { + id: uuid('id') + .primaryKey() + .default(sql`gen_random_uuid()`), + user_id: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + node_id: uuid('node_id') + .notNull() + .references(() => nodes.id, { onDelete: 'cascade' }), + channel_id: text('channel_id').notNull(), + note: text('note').notNull(), + created_at: timestamp('created_at', { precision: 6, mode: 'string' }) + .notNull() + .defaultNow(), + updated_at: timestamp('updated_at', { precision: 6, mode: 'string' }) + .notNull() + .defaultNow(), + }, + t => [unique().on(t.user_id, t.node_id, t.channel_id)] +); diff --git a/src/server/modules/database/schema/pg/index.ts b/src/server/modules/database/schema/pg/index.ts index e53c1690..ec4fa601 100644 --- a/src/server/modules/database/schema/pg/index.ts +++ b/src/server/modules/database/schema/pg/index.ts @@ -2,3 +2,4 @@ export { teams } from './teams'; export { users } from './users'; export { nodes } from './nodes'; export { userNodes } from './user-nodes'; +export { channelMetadata } from './channel-metadata'; diff --git a/src/server/modules/database/schema/sqlite/channel-metadata.ts b/src/server/modules/database/schema/sqlite/channel-metadata.ts new file mode 100644 index 00000000..d516c3c6 --- /dev/null +++ b/src/server/modules/database/schema/sqlite/channel-metadata.ts @@ -0,0 +1,28 @@ +import { sqliteTable, text, unique } from 'drizzle-orm/sqlite-core'; +import { sql } from 'drizzle-orm'; +import { users } from './users'; +import { nodes } from './nodes'; + +export const channelMetadata = sqliteTable( + 'channel_metadata', + { + id: text('id') + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + user_id: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + node_id: text('node_id') + .notNull() + .references(() => nodes.id, { onDelete: 'cascade' }), + channel_id: text('channel_id').notNull(), + note: text('note').notNull(), + created_at: text('created_at') + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), + updated_at: text('updated_at') + .notNull() + .default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`), + }, + t => [unique().on(t.user_id, t.node_id, t.channel_id)] +); diff --git a/src/server/modules/database/schema/sqlite/index.ts b/src/server/modules/database/schema/sqlite/index.ts index e53c1690..ec4fa601 100644 --- a/src/server/modules/database/schema/sqlite/index.ts +++ b/src/server/modules/database/schema/sqlite/index.ts @@ -2,3 +2,4 @@ export { teams } from './teams'; export { users } from './users'; export { nodes } from './nodes'; export { userNodes } from './user-nodes'; +export { channelMetadata } from './channel-metadata'; diff --git a/src/server/modules/dataloader/dataloader.module.ts b/src/server/modules/dataloader/dataloader.module.ts index 5829aa3a..252d2da9 100644 --- a/src/server/modules/dataloader/dataloader.module.ts +++ b/src/server/modules/dataloader/dataloader.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { DataloaderService } from './dataloader.service'; import { AmbossModule } from '../api/amboss/amboss.module'; +import { ChannelMetadataService } from '../api/channels/channel-metadata.service'; @Module({ imports: [AmbossModule], - providers: [DataloaderService], + providers: [DataloaderService, ChannelMetadataService], exports: [DataloaderService], }) export class DataloaderModule {} diff --git a/src/server/modules/dataloader/dataloader.service.ts b/src/server/modules/dataloader/dataloader.service.ts index 7cfdf0c0..40fae500 100644 --- a/src/server/modules/dataloader/dataloader.service.ts +++ b/src/server/modules/dataloader/dataloader.service.ts @@ -2,15 +2,26 @@ import { Injectable } from '@nestjs/common'; import DataLoader from 'dataloader'; import { AmbossService } from '../api/amboss/amboss.service'; import { EdgeInfo, NodeAlias } from '../api/amboss/amboss.types'; +import { ChannelMetadataService } from '../api/channels/channel-metadata.service'; + +export type ChannelNoteKey = { + userId: string; + nodeId: string; + channelId: string; +}; export type DataloaderTypes = { nodesLoader: DataLoader; edgesLoader: DataLoader; + channelNotesLoader: DataLoader; }; @Injectable() export class DataloaderService { - constructor(private ambossService: AmbossService) {} + constructor( + private ambossService: AmbossService, + private channelMetadataService: ChannelMetadataService + ) {} createLoaders(): DataloaderTypes { const nodesLoader = new DataLoader( @@ -21,9 +32,45 @@ export class DataloaderService { async (ids: string[]) => this.ambossService.getEdgeInfoBatch(ids) ); + const channelNotesLoader = new DataLoader< + ChannelNoteKey, + string | null, + string + >( + async (keys: readonly ChannelNoteKey[]) => { + const grouped = new Map(); + for (const key of keys) { + const groupKey = `${key.userId}:${key.nodeId}`; + const group = grouped.get(groupKey) || []; + group.push(key); + grouped.set(groupKey, group); + } + + const noteMaps = new Map>(); + for (const [groupKey, group] of grouped) { + const { userId, nodeId } = group[0]; + const map = await this.channelMetadataService.getNotesByNode( + userId, + nodeId + ); + noteMaps.set(groupKey, map); + } + + return keys.map(key => { + const groupKey = `${key.userId}:${key.nodeId}`; + return noteMaps.get(groupKey)?.get(key.channelId) ?? null; + }); + }, + { + cacheKeyFn: (key: ChannelNoteKey) => + `${key.userId}:${key.nodeId}:${key.channelId}`, + } + ); + return { nodesLoader, edgesLoader, + channelNotesLoader, }; } }