feat: per-channel notes (inline editing in channels table) (#744)

* feat(db): add channel_notes table with Drizzle schema and migrations

Adds a new `channel_notes` table (SQLite + PG) with composite PK
(account_id, channel_id) to persist per-channel notes scoped to each
ThunderHub account. Includes migration files for both database backends.

* feat(api): add ChannelNotesService with getChannelNotes query and setChannelNote mutation

Introduces ChannelNotesService (Drizzle-backed, account-scoped) with
getNotes/upsertNote methods. Exposes two new GraphQL endpoints on
ChannelsResolver: getChannelNotes (query) and setChannelNote (mutation).

* feat(client): add inline channel notes to channels table and details panel

- Adds NoteCell component to ChannelTable with click-to-edit inline input
  (Enter/✓ to save, Escape to cancel)
- Fetches all notes on load via getChannelNotes query; updates optimistically
- Adds note textarea + Save button in ChannelDetails modal below fee editor
- Notes are persisted server-side and survive page refreshes

* chore: table changes

* chore: schema cleanup

* chore: cleanup table

* chore: more cleanup

* chore: db ondelete

---------

Co-authored-by: Anthony Potdevin <potdevin.anthony@gmail.com>
This commit is contained in:
Kilombino 2026-04-30 05:31:54 +02:00 committed by GitHub
parent fa1de4dd7f
commit f62b48fa24
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1659 additions and 15 deletions

View file

@ -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;

View file

@ -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": {}
}
}

View file

@ -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
}
]
}

View file

@ -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`);

View file

@ -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": {}
}
}

View file

@ -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
}
]
}

View file

@ -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!

View file

@ -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<UpsertChannelNoteMutation>;
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<DeleteChannelNoteMutation>;
export type DeleteChannelNoteMutationOptions = Apollo.BaseMutationOptions<
DeleteChannelNoteMutation,
DeleteChannelNoteMutationVariables
>;

View file

@ -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)
}
}
}
}
`;

View file

@ -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

View file

@ -26,6 +26,7 @@ export const GET_CHANNELS = gql`
transaction_vout
unsettled_balance
channel_age
note
pending_resume {
incoming_tokens
outgoing_tokens

View file

@ -202,6 +202,7 @@ export type Channel = {
is_private: Scalars['Boolean']['output'];
local_balance: Scalars['Float']['output'];
local_reserve: Scalars['Float']['output'];
note?: Maybe<Scalars['String']['output']>;
partner_fee_info?: Maybe<SingleChannel>;
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<Scalars['String']['input']>;
@ -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'];

View file

@ -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,

View file

@ -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 = (
<button
onClick={e => {
e.stopPropagation();
handleOpen();
}}
className="inline-flex items-center gap-1.5 border-none bg-transparent p-0 cursor-pointer text-muted-foreground hover:text-foreground"
>
{note ? (
<>
<span className="text-xs max-w-[120px] truncate">{truncated}</span>
<Edit size={14} className="shrink-0" />
</>
) : (
<span className="text-base opacity-30 select-none">+</span>
)}
</button>
);
return (
<>
{note.length > 10 ? (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent className="max-w-xs wrap-break-words">
{note}
</TooltipContent>
</Tooltip>
) : (
trigger
)}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent onClick={e => e.stopPropagation()}>
<DialogHeader>
<DialogTitle>Channel Note</DialogTitle>
</DialogHeader>
{isDbAccount ? (
<>
<Input
autoFocus
value={value}
maxLength={500}
onChange={e => setValue(e.target.value)}
placeholder="Personal note for this channel..."
onKeyDown={e => {
if (e.key === 'Enter' && value.trim()) handleSave();
}}
/>
<DialogFooter>
{note && (
<Button
variant="destructive"
size="sm"
disabled={deleting}
onClick={handleDelete}
>
<Trash2 size={14} />
Delete
</Button>
)}
<Button
size="sm"
disabled={saving || !value.trim()}
onClick={handleSave}
>
Save
</Button>
</DialogFooter>
</>
) : (
<p className="text-sm text-muted-foreground text-balance">
Channel notes require a database account.{' '}
<a
href="https://docs.thunderhub.io/setup#database-optional"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-3 hover:text-foreground"
>
Learn how to set up a database.
</a>
</p>
)}
</DialogContent>
</Dialog>
</>
);
};
// ── 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<string, any> = {};
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 = ({
</div>
),
},
{
header: 'Note',
accessorKey: 'note',
enableSorting: false,
cell: ({ row }: any) => (
<NoteCell
note={row.original.note}
channelId={row.original.id}
isDbAccount={isDbAccount}
/>
),
},
{
header: 'Capacity',
accessorKey: 'capacity',
@ -641,7 +824,7 @@ export const ChannelTable = ({
};
}),
],
[numberStringSorting, uniqueAssets]
[numberStringSorting, uniqueAssets, isDbAccount]
);
const handleToggle = (hide: boolean, id: string) => {

View file

@ -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<Map<string, string>> {
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<string, string>();
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<ChannelMetadata> {
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<boolean> {
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;
}
}

View file

@ -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;
}

View file

@ -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<string | null> {
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,
});
}
}

View file

@ -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 {}

View file

@ -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<UserMutations> {
return {} as any;
}
}
@Resolver(() => UserMutations)
export class UserMutationsResolver {
@ResolveField(() => OffchainMutations)
async offchain(): Promise<OffchainMutations> {
return {} as any;
}
}
@Resolver(() => OffchainMutations)
export class OffchainMutationsResolver {
@ResolveField(() => ChannelsMutations)
async channels(): Promise<ChannelsMutations> {
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<ChannelMetadata> {
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<boolean> {
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);
}
}

View file

@ -169,6 +169,8 @@ export class Channel {
pending_resume: PendingResume;
@Field(() => ChannelAsset, { nullable: true })
asset?: ChannelAsset;
@Field({ nullable: true })
note?: string;
}
export type SingleChannelParentType = {

View file

@ -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)]
);

View file

@ -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';

View file

@ -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)]
);

View file

@ -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';

View file

@ -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 {}

View file

@ -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<string, NodeAlias>;
edgesLoader: DataLoader<string, EdgeInfo>;
channelNotesLoader: DataLoader<ChannelNoteKey, string | null>;
};
@Injectable()
export class DataloaderService {
constructor(private ambossService: AmbossService) {}
constructor(
private ambossService: AmbossService,
private channelMetadataService: ChannelMetadataService
) {}
createLoaders(): DataloaderTypes {
const nodesLoader = new DataLoader<string, NodeAlias>(
@ -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<string, ChannelNoteKey[]>();
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<string, Map<string, string>>();
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,
};
}
}