From 6a0be1ed5a7ec9531af20a5196a8be1d322a0422 Mon Sep 17 00:00:00 2001 From: Marvin Charles Date: Thu, 11 Jun 2026 10:39:38 -0500 Subject: [PATCH 1/7] feat(notes): add shared team notepad server API One org-wide, append-style feed every member can read and post to. Authors edit/delete their own notes; note:manage holders moderate (delete any note). Ships the full server stack: note table + permission seed migrations, note:read/create/manage catalog keys (FIELD_TECH seeded with read+create), NoteService with keyset pagination, and activity-log events for every mutation. Notepad gates use new any-scope middleware (HasAnywhere): the feed is an org-shared resource with no site dimension, so a role assignment at any scope -- including a site-only FIELD_TECH -- passes, matching the client's flat-key permission gating. --- .../api/generated/notes/v1/notes_pb.ts | 278 ++++++++ proto/notes/v1/notes.proto | 85 +++ server/cmd/fleetd/main.go | 6 + server/generated/grpc/notes/v1/notes.pb.go | 597 ++++++++++++++++++ .../notes/v1/notesv1connect/notes.connect.go | 211 +++++++ server/generated/sqlc/db.go | 50 ++ server/generated/sqlc/models.go | 10 + server/generated/sqlc/note.sql.go | 203 ++++++ .../internal/domain/activity/models/models.go | 3 +- .../domain/activity/models/models_test.go | 1 + server/internal/domain/authz/builtin.go | 4 +- server/internal/domain/authz/catalog.go | 12 + server/internal/domain/authz/catalog_test.go | 4 + server/internal/domain/authz/effective.go | 25 + .../internal/domain/authz/effective_test.go | 71 +++ .../internal/domain/authz/reconcile_test.go | 4 + server/internal/domain/authz/service_test.go | 16 + server/internal/domain/notes/models/models.go | 44 ++ server/internal/domain/notes/service.go | 169 +++++ server/internal/domain/notes/service_test.go | 175 +++++ .../interfaces/mocks/mock_note_store.go | 116 ++++ .../internal/domain/stores/interfaces/note.go | 38 ++ .../internal/domain/stores/sqlstores/note.go | 118 ++++ .../stores/sqlstores/note_integration_test.go | 152 +++++ .../handlers/handlerstest/permissions.go | 27 + .../handlers/middleware/permission.go | 120 ++++ .../handlers/middleware/permission_test.go | 143 +++++ .../handlers/middleware/rpc_permissions.go | 13 + .../middleware/rpc_permissions_test.go | 2 + server/internal/handlers/notes/handler.go | 151 +++++ .../internal/handlers/notes/handler_test.go | 320 ++++++++++ server/internal/handlers/notes/translate.go | 26 + .../000081_create_note_table.down.sql | 3 + .../000081_create_note_table.up.sql | 30 + .../000082_seed_note_permissions.down.sql | 16 + .../000082_seed_note_permissions.up.sql | 37 ++ server/sqlc/queries/note.sql | 51 ++ 37 files changed, 3329 insertions(+), 2 deletions(-) create mode 100644 client/src/protoFleet/api/generated/notes/v1/notes_pb.ts create mode 100644 proto/notes/v1/notes.proto create mode 100644 server/generated/grpc/notes/v1/notes.pb.go create mode 100644 server/generated/grpc/notes/v1/notesv1connect/notes.connect.go create mode 100644 server/generated/sqlc/note.sql.go create mode 100644 server/internal/domain/notes/models/models.go create mode 100644 server/internal/domain/notes/service.go create mode 100644 server/internal/domain/notes/service_test.go create mode 100644 server/internal/domain/stores/interfaces/mocks/mock_note_store.go create mode 100644 server/internal/domain/stores/interfaces/note.go create mode 100644 server/internal/domain/stores/sqlstores/note.go create mode 100644 server/internal/domain/stores/sqlstores/note_integration_test.go create mode 100644 server/internal/handlers/notes/handler.go create mode 100644 server/internal/handlers/notes/handler_test.go create mode 100644 server/internal/handlers/notes/translate.go create mode 100644 server/migrations/000081_create_note_table.down.sql create mode 100644 server/migrations/000081_create_note_table.up.sql create mode 100644 server/migrations/000082_seed_note_permissions.down.sql create mode 100644 server/migrations/000082_seed_note_permissions.up.sql create mode 100644 server/sqlc/queries/note.sql diff --git a/client/src/protoFleet/api/generated/notes/v1/notes_pb.ts b/client/src/protoFleet/api/generated/notes/v1/notes_pb.ts new file mode 100644 index 0000000000..dbf7fc6e75 --- /dev/null +++ b/client/src/protoFleet/api/generated/notes/v1/notes_pb.ts @@ -0,0 +1,278 @@ +// @generated by protoc-gen-es v2.12.0 with parameter "target=ts" +// @generated from file notes/v1/notes.proto (package notes.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../buf/validate/validate_pb"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file notes/v1/notes.proto. + */ +export const file_notes_v1_notes: GenFile = + /*@__PURE__*/ + fileDesc( + "ChRub3Rlcy92MS9ub3Rlcy5wcm90bxIIbm90ZXMudjEinAEKBE5vdGUSCgoCaWQYASABKAMSDwoHY29udGVudBgCIAEoCRIXCg9hdXRob3JfdXNlcm5hbWUYAyABKAkSLgoKY3JlYXRlZF9hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLgoKdXBkYXRlZF9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiRAoQTGlzdE5vdGVzUmVxdWVzdBIcCglwYWdlX3NpemUYASABKAVCCbpIBhoEGGQoARISCgpwYWdlX3Rva2VuGAIgASgJIksKEUxpc3ROb3Rlc1Jlc3BvbnNlEh0KBW5vdGVzGAEgAygLMg4ubm90ZXMudjEuTm90ZRIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkiMAoRQ3JlYXRlTm90ZVJlcXVlc3QSGwoHY29udGVudBgBIAEoCUIKukgHcgUQARiAICIyChJDcmVhdGVOb3RlUmVzcG9uc2USHAoEbm90ZRgBIAEoCzIOLm5vdGVzLnYxLk5vdGUiRQoRVXBkYXRlTm90ZVJlcXVlc3QSEwoCaWQYASABKANCB7pIBCICIAASGwoHY29udGVudBgCIAEoCUIKukgHcgUQARiAICIyChJVcGRhdGVOb3RlUmVzcG9uc2USHAoEbm90ZRgBIAEoCzIOLm5vdGVzLnYxLk5vdGUiKAoRRGVsZXRlTm90ZVJlcXVlc3QSEwoCaWQYASABKANCB7pIBCICIAAiFAoSRGVsZXRlTm90ZVJlc3BvbnNlMq4CCgtOb3RlU2VydmljZRJECglMaXN0Tm90ZXMSGi5ub3Rlcy52MS5MaXN0Tm90ZXNSZXF1ZXN0Ghsubm90ZXMudjEuTGlzdE5vdGVzUmVzcG9uc2USRwoKQ3JlYXRlTm90ZRIbLm5vdGVzLnYxLkNyZWF0ZU5vdGVSZXF1ZXN0Ghwubm90ZXMudjEuQ3JlYXRlTm90ZVJlc3BvbnNlEkcKClVwZGF0ZU5vdGUSGy5ub3Rlcy52MS5VcGRhdGVOb3RlUmVxdWVzdBocLm5vdGVzLnYxLlVwZGF0ZU5vdGVSZXNwb25zZRJHCgpEZWxldGVOb3RlEhsubm90ZXMudjEuRGVsZXRlTm90ZVJlcXVlc3QaHC5ub3Rlcy52MS5EZWxldGVOb3RlUmVzcG9uc2VCoAEKDGNvbS5ub3Rlcy52MUIKTm90ZXNQcm90b1ABWkNnaXRodWIuY29tL2Jsb2NrL3Byb3RvLWZsZWV0L3NlcnZlci9nZW5lcmF0ZWQvZ3JwYy9ub3Rlcy92MTtub3Rlc3YxogIDTlhYqgIITm90ZXMuVjHKAghOb3Rlc1xWMeICFE5vdGVzXFYxXEdQQk1ldGFkYXRh6gIJTm90ZXM6OlYxYgZwcm90bzM", + [file_buf_validate_validate, file_google_protobuf_timestamp], + ); + +/** + * Note is one entry in the shared notepad. + * + * @generated from message notes.v1.Note + */ +export type Note = Message<"notes.v1.Note"> & { + /** + * @generated from field: int64 id = 1; + */ + id: bigint; + + /** + * @generated from field: string content = 2; + */ + content: string; + + /** + * Display attribution and the client's "own note" comparison key: + * usernames are unique and the client auth store carries exactly + * this value. The server enforces authorship from the session + * regardless of what the client renders. + * + * @generated from field: string author_username = 3; + */ + authorUsername: string; + + /** + * @generated from field: google.protobuf.Timestamp created_at = 4; + */ + createdAt?: Timestamp | undefined; + + /** + * updated_at > created_at means the note was edited — content + * edits are the only mutation a live note can receive. + * + * @generated from field: google.protobuf.Timestamp updated_at = 5; + */ + updatedAt?: Timestamp | undefined; +}; + +/** + * Describes the message notes.v1.Note. + * Use `create(NoteSchema)` to create a new message. + */ +export const NoteSchema: GenMessage = /*@__PURE__*/ messageDesc(file_notes_v1_notes, 0); + +/** + * @generated from message notes.v1.ListNotesRequest + */ +export type ListNotesRequest = Message<"notes.v1.ListNotesRequest"> & { + /** + * @generated from field: int32 page_size = 1; + */ + pageSize: number; + + /** + * Opaque cursor from a prior response. Empty requests the first + * (newest) page. + * + * @generated from field: string page_token = 2; + */ + pageToken: string; +}; + +/** + * Describes the message notes.v1.ListNotesRequest. + * Use `create(ListNotesRequestSchema)` to create a new message. + */ +export const ListNotesRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_notes_v1_notes, 1); + +/** + * @generated from message notes.v1.ListNotesResponse + */ +export type ListNotesResponse = Message<"notes.v1.ListNotesResponse"> & { + /** + * @generated from field: repeated notes.v1.Note notes = 1; + */ + notes: Note[]; + + /** + * Empty when there are no further pages. + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message notes.v1.ListNotesResponse. + * Use `create(ListNotesResponseSchema)` to create a new message. + */ +export const ListNotesResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_notes_v1_notes, 2); + +/** + * @generated from message notes.v1.CreateNoteRequest + */ +export type CreateNoteRequest = Message<"notes.v1.CreateNoteRequest"> & { + /** + * Server trims surrounding whitespace; must be non-empty after + * the trim. max_len counts codepoints, matching the domain cap. + * + * @generated from field: string content = 1; + */ + content: string; +}; + +/** + * Describes the message notes.v1.CreateNoteRequest. + * Use `create(CreateNoteRequestSchema)` to create a new message. + */ +export const CreateNoteRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_notes_v1_notes, 3); + +/** + * @generated from message notes.v1.CreateNoteResponse + */ +export type CreateNoteResponse = Message<"notes.v1.CreateNoteResponse"> & { + /** + * @generated from field: notes.v1.Note note = 1; + */ + note?: Note | undefined; +}; + +/** + * Describes the message notes.v1.CreateNoteResponse. + * Use `create(CreateNoteResponseSchema)` to create a new message. + */ +export const CreateNoteResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_notes_v1_notes, 4); + +/** + * @generated from message notes.v1.UpdateNoteRequest + */ +export type UpdateNoteRequest = Message<"notes.v1.UpdateNoteRequest"> & { + /** + * @generated from field: int64 id = 1; + */ + id: bigint; + + /** + * @generated from field: string content = 2; + */ + content: string; +}; + +/** + * Describes the message notes.v1.UpdateNoteRequest. + * Use `create(UpdateNoteRequestSchema)` to create a new message. + */ +export const UpdateNoteRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_notes_v1_notes, 5); + +/** + * @generated from message notes.v1.UpdateNoteResponse + */ +export type UpdateNoteResponse = Message<"notes.v1.UpdateNoteResponse"> & { + /** + * @generated from field: notes.v1.Note note = 1; + */ + note?: Note | undefined; +}; + +/** + * Describes the message notes.v1.UpdateNoteResponse. + * Use `create(UpdateNoteResponseSchema)` to create a new message. + */ +export const UpdateNoteResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_notes_v1_notes, 6); + +/** + * @generated from message notes.v1.DeleteNoteRequest + */ +export type DeleteNoteRequest = Message<"notes.v1.DeleteNoteRequest"> & { + /** + * @generated from field: int64 id = 1; + */ + id: bigint; +}; + +/** + * Describes the message notes.v1.DeleteNoteRequest. + * Use `create(DeleteNoteRequestSchema)` to create a new message. + */ +export const DeleteNoteRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_notes_v1_notes, 7); + +/** + * Empty: success/failure is carried by the RPC status. + * + * @generated from message notes.v1.DeleteNoteResponse + */ +export type DeleteNoteResponse = Message<"notes.v1.DeleteNoteResponse"> & {}; + +/** + * Describes the message notes.v1.DeleteNoteResponse. + * Use `create(DeleteNoteResponseSchema)` to create a new message. + */ +export const DeleteNoteResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_notes_v1_notes, 8); + +/** + * NoteService is the org-wide shared team notepad: one append-style + * feed every member of the org can read and post to. Authors edit and + * delete their own notes; note:manage holders can delete any note. + * All RPCs gate on the any-scope permission check — a role assignment + * at any scope (org or site) makes the caller a member of the team + * the feed serves. + * + * @generated from service notes.v1.NoteService + */ +export const NoteService: GenService<{ + /** + * ListNotes returns the org feed newest-first with keyset + * pagination. An empty next_page_token means the feed is + * exhausted. + * + * @generated from rpc notes.v1.NoteService.ListNotes + */ + listNotes: { + methodKind: "unary"; + input: typeof ListNotesRequestSchema; + output: typeof ListNotesResponseSchema; + }; + /** + * CreateNote appends a note authored by the caller. Content is + * trimmed server-side and must be non-empty after trimming. + * + * @generated from rpc notes.v1.NoteService.CreateNote + */ + createNote: { + methodKind: "unary"; + input: typeof CreateNoteRequestSchema; + output: typeof CreateNoteResponseSchema; + }; + /** + * UpdateNote edits the content of the caller's own note. Editing + * another author's note is rejected regardless of role. + * + * @generated from rpc notes.v1.NoteService.UpdateNote + */ + updateNote: { + methodKind: "unary"; + input: typeof UpdateNoteRequestSchema; + output: typeof UpdateNoteResponseSchema; + }; + /** + * DeleteNote soft-deletes the caller's own note, or any note when + * the caller holds note:manage. + * + * @generated from rpc notes.v1.NoteService.DeleteNote + */ + deleteNote: { + methodKind: "unary"; + input: typeof DeleteNoteRequestSchema; + output: typeof DeleteNoteResponseSchema; + }; +}> = /*@__PURE__*/ serviceDesc(file_notes_v1_notes, 0); diff --git a/proto/notes/v1/notes.proto b/proto/notes/v1/notes.proto new file mode 100644 index 0000000000..2350d189f5 --- /dev/null +++ b/proto/notes/v1/notes.proto @@ -0,0 +1,85 @@ +syntax = "proto3"; + +package notes.v1; + +import "buf/validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +// NoteService is the org-wide shared team notepad: one append-style +// feed every member of the org can read and post to. Authors edit and +// delete their own notes; note:manage holders can delete any note. +// All RPCs gate on the any-scope permission check — a role assignment +// at any scope (org or site) makes the caller a member of the team +// the feed serves. +service NoteService { + // ListNotes returns the org feed newest-first with keyset + // pagination. An empty next_page_token means the feed is + // exhausted. + rpc ListNotes(ListNotesRequest) returns (ListNotesResponse); + + // CreateNote appends a note authored by the caller. Content is + // trimmed server-side and must be non-empty after trimming. + rpc CreateNote(CreateNoteRequest) returns (CreateNoteResponse); + + // UpdateNote edits the content of the caller's own note. Editing + // another author's note is rejected regardless of role. + rpc UpdateNote(UpdateNoteRequest) returns (UpdateNoteResponse); + + // DeleteNote soft-deletes the caller's own note, or any note when + // the caller holds note:manage. + rpc DeleteNote(DeleteNoteRequest) returns (DeleteNoteResponse); +} + +// Note is one entry in the shared notepad. +message Note { + int64 id = 1; + string content = 2; + // Display attribution and the client's "own note" comparison key: + // usernames are unique and the client auth store carries exactly + // this value. The server enforces authorship from the session + // regardless of what the client renders. + string author_username = 3; + google.protobuf.Timestamp created_at = 4; + // updated_at > created_at means the note was edited — content + // edits are the only mutation a live note can receive. + google.protobuf.Timestamp updated_at = 5; +} + +message ListNotesRequest { + int32 page_size = 1 [(buf.validate.field).int32 = {gte: 1, lte: 100}]; + // Opaque cursor from a prior response. Empty requests the first + // (newest) page. + string page_token = 2; +} + +message ListNotesResponse { + repeated Note notes = 1; + // Empty when there are no further pages. + string next_page_token = 2; +} + +message CreateNoteRequest { + // Server trims surrounding whitespace; must be non-empty after + // the trim. max_len counts codepoints, matching the domain cap. + string content = 1 [(buf.validate.field).string = {min_len: 1, max_len: 4096}]; +} + +message CreateNoteResponse { + Note note = 1; +} + +message UpdateNoteRequest { + int64 id = 1 [(buf.validate.field).int64.gt = 0]; + string content = 2 [(buf.validate.field).string = {min_len: 1, max_len: 4096}]; +} + +message UpdateNoteResponse { + Note note = 1; +} + +message DeleteNoteRequest { + int64 id = 1 [(buf.validate.field).int64.gt = 0]; +} + +// Empty: success/failure is carried by the RPC status. +message DeleteNoteResponse {} diff --git a/server/cmd/fleetd/main.go b/server/cmd/fleetd/main.go index fbd366ae41..baad992a5b 100644 --- a/server/cmd/fleetd/main.go +++ b/server/cmd/fleetd/main.go @@ -52,6 +52,7 @@ import ( "github.com/block/proto-fleet/server/generated/grpc/foremanimport/v1/foremanimportv1connect" "github.com/block/proto-fleet/server/generated/grpc/minercommand/v1/minercommandv1connect" "github.com/block/proto-fleet/server/generated/grpc/networkinfo/v1/networkinfov1connect" + "github.com/block/proto-fleet/server/generated/grpc/notes/v1/notesv1connect" "github.com/block/proto-fleet/server/generated/grpc/onboarding/v1/onboardingv1connect" "github.com/block/proto-fleet/server/generated/grpc/pairing/v1/pairingv1connect" "github.com/block/proto-fleet/server/generated/grpc/pools/v1/poolsv1connect" @@ -79,6 +80,7 @@ import ( fleetnodepairing "github.com/block/proto-fleet/server/internal/domain/fleetnode/pairing" "github.com/block/proto-fleet/server/internal/domain/fleetoptions" foremanImportDomain "github.com/block/proto-fleet/server/internal/domain/foremanimport" + notesDomain "github.com/block/proto-fleet/server/internal/domain/notes" onboardingDomain "github.com/block/proto-fleet/server/internal/domain/onboarding" pairingDomain "github.com/block/proto-fleet/server/internal/domain/pairing" poolsDomain "github.com/block/proto-fleet/server/internal/domain/pools" @@ -107,6 +109,7 @@ import ( "github.com/block/proto-fleet/server/internal/handlers/interceptors" "github.com/block/proto-fleet/server/internal/handlers/middleware" "github.com/block/proto-fleet/server/internal/handlers/networkinfo" + notesHandler "github.com/block/proto-fleet/server/internal/handlers/notes" "github.com/block/proto-fleet/server/internal/handlers/onboarding" "github.com/block/proto-fleet/server/internal/handlers/pairing" "github.com/block/proto-fleet/server/internal/handlers/pools" @@ -454,6 +457,8 @@ func start(config *Config) error { sitesSvc := sitesDomain.NewService(siteStore, buildingStore, deviceStore, telemetryService, transactor, activitySvc) buildingsSvc := buildingsDomain.NewService(buildingStore, siteStore, collectionStore, deviceStore, telemetryService, transactor, activitySvc) + noteStore := sqlstores.NewSQLNoteStore(conn) + notesSvc := notesDomain.NewService(noteStore, activitySvc) // Register the schedule-conflict preflight filter on commandSvc so every // caller (manual API, schedule processor, future curtailment reconciler) @@ -588,6 +593,7 @@ func start(config *Config) error { mux.Handle(curtailmentv1connect.NewCurtailmentServiceHandler(curtailmentHandler.NewHandler(curtailmentSvc, mqttSettingsSvc), li)) mux.Handle(sitesv1connect.NewSiteServiceHandler(sitesHandler.NewHandler(sitesSvc), li)) mux.Handle(buildingsv1connect.NewBuildingServiceHandler(buildingsHandler.NewHandler(buildingsSvc), li)) + mux.Handle(notesv1connect.NewNoteServiceHandler(notesHandler.NewHandler(notesSvc), li)) mux.Handle(fleetnodegatewayv1connect.NewFleetNodeGatewayServiceHandler(gateway.NewHandler(fleetNodeEnrollmentSvc, fleetNodeAuthSvc, fleetNodePairingSvc, fleetNodeControlRegistry), li)) mux.Handle(fleetnodeadminv1connect.NewFleetNodeAdminServiceHandler(admin.NewHandler(fleetNodeEnrollmentSvc, fleetNodePairingSvc, fleetNodeDiscoverySvc), li)) mux.Handle(collectionv1connect.NewDeviceCollectionServiceHandler(collectionHandler.NewHandler(collectionSvc), li)) diff --git a/server/generated/grpc/notes/v1/notes.pb.go b/server/generated/grpc/notes/v1/notes.pb.go new file mode 100644 index 0000000000..71ec4a00a9 --- /dev/null +++ b/server/generated/grpc/notes/v1/notes.pb.go @@ -0,0 +1,597 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: notes/v1/notes.proto + +package notesv1 + +import ( + reflect "reflect" + sync "sync" + unsafe "unsafe" + + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Note is one entry in the shared notepad. +type Note struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + // Display attribution and the client's "own note" comparison key: + // usernames are unique and the client auth store carries exactly + // this value. The server enforces authorship from the session + // regardless of what the client renders. + AuthorUsername string `protobuf:"bytes,3,opt,name=author_username,json=authorUsername,proto3" json:"author_username,omitempty"` + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // updated_at > created_at means the note was edited — content + // edits are the only mutation a live note can receive. + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Note) Reset() { + *x = Note{} + mi := &file_notes_v1_notes_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Note) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Note) ProtoMessage() {} + +func (x *Note) ProtoReflect() protoreflect.Message { + mi := &file_notes_v1_notes_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Note.ProtoReflect.Descriptor instead. +func (*Note) Descriptor() ([]byte, []int) { + return file_notes_v1_notes_proto_rawDescGZIP(), []int{0} +} + +func (x *Note) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *Note) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +func (x *Note) GetAuthorUsername() string { + if x != nil { + return x.AuthorUsername + } + return "" +} + +func (x *Note) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *Note) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + +type ListNotesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // Opaque cursor from a prior response. Empty requests the first + // (newest) page. + PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListNotesRequest) Reset() { + *x = ListNotesRequest{} + mi := &file_notes_v1_notes_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListNotesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListNotesRequest) ProtoMessage() {} + +func (x *ListNotesRequest) ProtoReflect() protoreflect.Message { + mi := &file_notes_v1_notes_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListNotesRequest.ProtoReflect.Descriptor instead. +func (*ListNotesRequest) Descriptor() ([]byte, []int) { + return file_notes_v1_notes_proto_rawDescGZIP(), []int{1} +} + +func (x *ListNotesRequest) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListNotesRequest) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + +type ListNotesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Notes []*Note `protobuf:"bytes,1,rep,name=notes,proto3" json:"notes,omitempty"` + // Empty when there are no further pages. + NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListNotesResponse) Reset() { + *x = ListNotesResponse{} + mi := &file_notes_v1_notes_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListNotesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListNotesResponse) ProtoMessage() {} + +func (x *ListNotesResponse) ProtoReflect() protoreflect.Message { + mi := &file_notes_v1_notes_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListNotesResponse.ProtoReflect.Descriptor instead. +func (*ListNotesResponse) Descriptor() ([]byte, []int) { + return file_notes_v1_notes_proto_rawDescGZIP(), []int{2} +} + +func (x *ListNotesResponse) GetNotes() []*Note { + if x != nil { + return x.Notes + } + return nil +} + +func (x *ListNotesResponse) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +type CreateNoteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Server trims surrounding whitespace; must be non-empty after + // the trim. max_len counts codepoints, matching the domain cap. + Content string `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateNoteRequest) Reset() { + *x = CreateNoteRequest{} + mi := &file_notes_v1_notes_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateNoteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateNoteRequest) ProtoMessage() {} + +func (x *CreateNoteRequest) ProtoReflect() protoreflect.Message { + mi := &file_notes_v1_notes_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateNoteRequest.ProtoReflect.Descriptor instead. +func (*CreateNoteRequest) Descriptor() ([]byte, []int) { + return file_notes_v1_notes_proto_rawDescGZIP(), []int{3} +} + +func (x *CreateNoteRequest) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +type CreateNoteResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Note *Note `protobuf:"bytes,1,opt,name=note,proto3" json:"note,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateNoteResponse) Reset() { + *x = CreateNoteResponse{} + mi := &file_notes_v1_notes_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateNoteResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateNoteResponse) ProtoMessage() {} + +func (x *CreateNoteResponse) ProtoReflect() protoreflect.Message { + mi := &file_notes_v1_notes_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateNoteResponse.ProtoReflect.Descriptor instead. +func (*CreateNoteResponse) Descriptor() ([]byte, []int) { + return file_notes_v1_notes_proto_rawDescGZIP(), []int{4} +} + +func (x *CreateNoteResponse) GetNote() *Note { + if x != nil { + return x.Note + } + return nil +} + +type UpdateNoteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateNoteRequest) Reset() { + *x = UpdateNoteRequest{} + mi := &file_notes_v1_notes_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateNoteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateNoteRequest) ProtoMessage() {} + +func (x *UpdateNoteRequest) ProtoReflect() protoreflect.Message { + mi := &file_notes_v1_notes_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateNoteRequest.ProtoReflect.Descriptor instead. +func (*UpdateNoteRequest) Descriptor() ([]byte, []int) { + return file_notes_v1_notes_proto_rawDescGZIP(), []int{5} +} + +func (x *UpdateNoteRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *UpdateNoteRequest) GetContent() string { + if x != nil { + return x.Content + } + return "" +} + +type UpdateNoteResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Note *Note `protobuf:"bytes,1,opt,name=note,proto3" json:"note,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateNoteResponse) Reset() { + *x = UpdateNoteResponse{} + mi := &file_notes_v1_notes_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateNoteResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateNoteResponse) ProtoMessage() {} + +func (x *UpdateNoteResponse) ProtoReflect() protoreflect.Message { + mi := &file_notes_v1_notes_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateNoteResponse.ProtoReflect.Descriptor instead. +func (*UpdateNoteResponse) Descriptor() ([]byte, []int) { + return file_notes_v1_notes_proto_rawDescGZIP(), []int{6} +} + +func (x *UpdateNoteResponse) GetNote() *Note { + if x != nil { + return x.Note + } + return nil +} + +type DeleteNoteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteNoteRequest) Reset() { + *x = DeleteNoteRequest{} + mi := &file_notes_v1_notes_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteNoteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteNoteRequest) ProtoMessage() {} + +func (x *DeleteNoteRequest) ProtoReflect() protoreflect.Message { + mi := &file_notes_v1_notes_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteNoteRequest.ProtoReflect.Descriptor instead. +func (*DeleteNoteRequest) Descriptor() ([]byte, []int) { + return file_notes_v1_notes_proto_rawDescGZIP(), []int{7} +} + +func (x *DeleteNoteRequest) GetId() int64 { + if x != nil { + return x.Id + } + return 0 +} + +// Empty: success/failure is carried by the RPC status. +type DeleteNoteResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteNoteResponse) Reset() { + *x = DeleteNoteResponse{} + mi := &file_notes_v1_notes_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteNoteResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteNoteResponse) ProtoMessage() {} + +func (x *DeleteNoteResponse) ProtoReflect() protoreflect.Message { + mi := &file_notes_v1_notes_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteNoteResponse.ProtoReflect.Descriptor instead. +func (*DeleteNoteResponse) Descriptor() ([]byte, []int) { + return file_notes_v1_notes_proto_rawDescGZIP(), []int{8} +} + +var File_notes_v1_notes_proto protoreflect.FileDescriptor + +const file_notes_v1_notes_proto_rawDesc = "" + + "\n" + + "\x14notes/v1/notes.proto\x12\bnotes.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xcf\x01\n" + + "\x04Note\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x03R\x02id\x12\x18\n" + + "\acontent\x18\x02 \x01(\tR\acontent\x12'\n" + + "\x0fauthor_username\x18\x03 \x01(\tR\x0eauthorUsername\x129\n" + + "\n" + + "created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x129\n" + + "\n" + + "updated_at\x18\x05 \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\"Y\n" + + "\x10ListNotesRequest\x12&\n" + + "\tpage_size\x18\x01 \x01(\x05B\t\xbaH\x06\x1a\x04\x18d(\x01R\bpageSize\x12\x1d\n" + + "\n" + + "page_token\x18\x02 \x01(\tR\tpageToken\"a\n" + + "\x11ListNotesResponse\x12$\n" + + "\x05notes\x18\x01 \x03(\v2\x0e.notes.v1.NoteR\x05notes\x12&\n" + + "\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\"9\n" + + "\x11CreateNoteRequest\x12$\n" + + "\acontent\x18\x01 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80 R\acontent\"8\n" + + "\x12CreateNoteResponse\x12\"\n" + + "\x04note\x18\x01 \x01(\v2\x0e.notes.v1.NoteR\x04note\"R\n" + + "\x11UpdateNoteRequest\x12\x17\n" + + "\x02id\x18\x01 \x01(\x03B\a\xbaH\x04\"\x02 \x00R\x02id\x12$\n" + + "\acontent\x18\x02 \x01(\tB\n" + + "\xbaH\ar\x05\x10\x01\x18\x80 R\acontent\"8\n" + + "\x12UpdateNoteResponse\x12\"\n" + + "\x04note\x18\x01 \x01(\v2\x0e.notes.v1.NoteR\x04note\",\n" + + "\x11DeleteNoteRequest\x12\x17\n" + + "\x02id\x18\x01 \x01(\x03B\a\xbaH\x04\"\x02 \x00R\x02id\"\x14\n" + + "\x12DeleteNoteResponse2\xae\x02\n" + + "\vNoteService\x12D\n" + + "\tListNotes\x12\x1a.notes.v1.ListNotesRequest\x1a\x1b.notes.v1.ListNotesResponse\x12G\n" + + "\n" + + "CreateNote\x12\x1b.notes.v1.CreateNoteRequest\x1a\x1c.notes.v1.CreateNoteResponse\x12G\n" + + "\n" + + "UpdateNote\x12\x1b.notes.v1.UpdateNoteRequest\x1a\x1c.notes.v1.UpdateNoteResponse\x12G\n" + + "\n" + + "DeleteNote\x12\x1b.notes.v1.DeleteNoteRequest\x1a\x1c.notes.v1.DeleteNoteResponseB\xa0\x01\n" + + "\fcom.notes.v1B\n" + + "NotesProtoP\x01ZCgithub.com/block/proto-fleet/server/generated/grpc/notes/v1;notesv1\xa2\x02\x03NXX\xaa\x02\bNotes.V1\xca\x02\bNotes\\V1\xe2\x02\x14Notes\\V1\\GPBMetadata\xea\x02\tNotes::V1b\x06proto3" + +var ( + file_notes_v1_notes_proto_rawDescOnce sync.Once + file_notes_v1_notes_proto_rawDescData []byte +) + +func file_notes_v1_notes_proto_rawDescGZIP() []byte { + file_notes_v1_notes_proto_rawDescOnce.Do(func() { + file_notes_v1_notes_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_notes_v1_notes_proto_rawDesc), len(file_notes_v1_notes_proto_rawDesc))) + }) + return file_notes_v1_notes_proto_rawDescData +} + +var file_notes_v1_notes_proto_msgTypes = make([]protoimpl.MessageInfo, 9) +var file_notes_v1_notes_proto_goTypes = []any{ + (*Note)(nil), // 0: notes.v1.Note + (*ListNotesRequest)(nil), // 1: notes.v1.ListNotesRequest + (*ListNotesResponse)(nil), // 2: notes.v1.ListNotesResponse + (*CreateNoteRequest)(nil), // 3: notes.v1.CreateNoteRequest + (*CreateNoteResponse)(nil), // 4: notes.v1.CreateNoteResponse + (*UpdateNoteRequest)(nil), // 5: notes.v1.UpdateNoteRequest + (*UpdateNoteResponse)(nil), // 6: notes.v1.UpdateNoteResponse + (*DeleteNoteRequest)(nil), // 7: notes.v1.DeleteNoteRequest + (*DeleteNoteResponse)(nil), // 8: notes.v1.DeleteNoteResponse + (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp +} +var file_notes_v1_notes_proto_depIdxs = []int32{ + 9, // 0: notes.v1.Note.created_at:type_name -> google.protobuf.Timestamp + 9, // 1: notes.v1.Note.updated_at:type_name -> google.protobuf.Timestamp + 0, // 2: notes.v1.ListNotesResponse.notes:type_name -> notes.v1.Note + 0, // 3: notes.v1.CreateNoteResponse.note:type_name -> notes.v1.Note + 0, // 4: notes.v1.UpdateNoteResponse.note:type_name -> notes.v1.Note + 1, // 5: notes.v1.NoteService.ListNotes:input_type -> notes.v1.ListNotesRequest + 3, // 6: notes.v1.NoteService.CreateNote:input_type -> notes.v1.CreateNoteRequest + 5, // 7: notes.v1.NoteService.UpdateNote:input_type -> notes.v1.UpdateNoteRequest + 7, // 8: notes.v1.NoteService.DeleteNote:input_type -> notes.v1.DeleteNoteRequest + 2, // 9: notes.v1.NoteService.ListNotes:output_type -> notes.v1.ListNotesResponse + 4, // 10: notes.v1.NoteService.CreateNote:output_type -> notes.v1.CreateNoteResponse + 6, // 11: notes.v1.NoteService.UpdateNote:output_type -> notes.v1.UpdateNoteResponse + 8, // 12: notes.v1.NoteService.DeleteNote:output_type -> notes.v1.DeleteNoteResponse + 9, // [9:13] is the sub-list for method output_type + 5, // [5:9] is the sub-list for method input_type + 5, // [5:5] is the sub-list for extension type_name + 5, // [5:5] is the sub-list for extension extendee + 0, // [0:5] is the sub-list for field type_name +} + +func init() { file_notes_v1_notes_proto_init() } +func file_notes_v1_notes_proto_init() { + if File_notes_v1_notes_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_notes_v1_notes_proto_rawDesc), len(file_notes_v1_notes_proto_rawDesc)), + NumEnums: 0, + NumMessages: 9, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_notes_v1_notes_proto_goTypes, + DependencyIndexes: file_notes_v1_notes_proto_depIdxs, + MessageInfos: file_notes_v1_notes_proto_msgTypes, + }.Build() + File_notes_v1_notes_proto = out.File + file_notes_v1_notes_proto_goTypes = nil + file_notes_v1_notes_proto_depIdxs = nil +} diff --git a/server/generated/grpc/notes/v1/notesv1connect/notes.connect.go b/server/generated/grpc/notes/v1/notesv1connect/notes.connect.go new file mode 100644 index 0000000000..807a0c78ef --- /dev/null +++ b/server/generated/grpc/notes/v1/notesv1connect/notes.connect.go @@ -0,0 +1,211 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: notes/v1/notes.proto + +package notesv1connect + +import ( + context "context" + errors "errors" + http "net/http" + strings "strings" + + connect "connectrpc.com/connect" + v1 "github.com/block/proto-fleet/server/generated/grpc/notes/v1" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // NoteServiceName is the fully-qualified name of the NoteService service. + NoteServiceName = "notes.v1.NoteService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // NoteServiceListNotesProcedure is the fully-qualified name of the NoteService's ListNotes RPC. + NoteServiceListNotesProcedure = "/notes.v1.NoteService/ListNotes" + // NoteServiceCreateNoteProcedure is the fully-qualified name of the NoteService's CreateNote RPC. + NoteServiceCreateNoteProcedure = "/notes.v1.NoteService/CreateNote" + // NoteServiceUpdateNoteProcedure is the fully-qualified name of the NoteService's UpdateNote RPC. + NoteServiceUpdateNoteProcedure = "/notes.v1.NoteService/UpdateNote" + // NoteServiceDeleteNoteProcedure is the fully-qualified name of the NoteService's DeleteNote RPC. + NoteServiceDeleteNoteProcedure = "/notes.v1.NoteService/DeleteNote" +) + +// NoteServiceClient is a client for the notes.v1.NoteService service. +type NoteServiceClient interface { + // ListNotes returns the org feed newest-first with keyset + // pagination. An empty next_page_token means the feed is + // exhausted. + ListNotes(context.Context, *connect.Request[v1.ListNotesRequest]) (*connect.Response[v1.ListNotesResponse], error) + // CreateNote appends a note authored by the caller. Content is + // trimmed server-side and must be non-empty after trimming. + CreateNote(context.Context, *connect.Request[v1.CreateNoteRequest]) (*connect.Response[v1.CreateNoteResponse], error) + // UpdateNote edits the content of the caller's own note. Editing + // another author's note is rejected regardless of role. + UpdateNote(context.Context, *connect.Request[v1.UpdateNoteRequest]) (*connect.Response[v1.UpdateNoteResponse], error) + // DeleteNote soft-deletes the caller's own note, or any note when + // the caller holds note:manage. + DeleteNote(context.Context, *connect.Request[v1.DeleteNoteRequest]) (*connect.Response[v1.DeleteNoteResponse], error) +} + +// NewNoteServiceClient constructs a client for the notes.v1.NoteService service. By default, it +// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewNoteServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) NoteServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + noteServiceMethods := v1.File_notes_v1_notes_proto.Services().ByName("NoteService").Methods() + return ¬eServiceClient{ + listNotes: connect.NewClient[v1.ListNotesRequest, v1.ListNotesResponse]( + httpClient, + baseURL+NoteServiceListNotesProcedure, + connect.WithSchema(noteServiceMethods.ByName("ListNotes")), + connect.WithClientOptions(opts...), + ), + createNote: connect.NewClient[v1.CreateNoteRequest, v1.CreateNoteResponse]( + httpClient, + baseURL+NoteServiceCreateNoteProcedure, + connect.WithSchema(noteServiceMethods.ByName("CreateNote")), + connect.WithClientOptions(opts...), + ), + updateNote: connect.NewClient[v1.UpdateNoteRequest, v1.UpdateNoteResponse]( + httpClient, + baseURL+NoteServiceUpdateNoteProcedure, + connect.WithSchema(noteServiceMethods.ByName("UpdateNote")), + connect.WithClientOptions(opts...), + ), + deleteNote: connect.NewClient[v1.DeleteNoteRequest, v1.DeleteNoteResponse]( + httpClient, + baseURL+NoteServiceDeleteNoteProcedure, + connect.WithSchema(noteServiceMethods.ByName("DeleteNote")), + connect.WithClientOptions(opts...), + ), + } +} + +// noteServiceClient implements NoteServiceClient. +type noteServiceClient struct { + listNotes *connect.Client[v1.ListNotesRequest, v1.ListNotesResponse] + createNote *connect.Client[v1.CreateNoteRequest, v1.CreateNoteResponse] + updateNote *connect.Client[v1.UpdateNoteRequest, v1.UpdateNoteResponse] + deleteNote *connect.Client[v1.DeleteNoteRequest, v1.DeleteNoteResponse] +} + +// ListNotes calls notes.v1.NoteService.ListNotes. +func (c *noteServiceClient) ListNotes(ctx context.Context, req *connect.Request[v1.ListNotesRequest]) (*connect.Response[v1.ListNotesResponse], error) { + return c.listNotes.CallUnary(ctx, req) +} + +// CreateNote calls notes.v1.NoteService.CreateNote. +func (c *noteServiceClient) CreateNote(ctx context.Context, req *connect.Request[v1.CreateNoteRequest]) (*connect.Response[v1.CreateNoteResponse], error) { + return c.createNote.CallUnary(ctx, req) +} + +// UpdateNote calls notes.v1.NoteService.UpdateNote. +func (c *noteServiceClient) UpdateNote(ctx context.Context, req *connect.Request[v1.UpdateNoteRequest]) (*connect.Response[v1.UpdateNoteResponse], error) { + return c.updateNote.CallUnary(ctx, req) +} + +// DeleteNote calls notes.v1.NoteService.DeleteNote. +func (c *noteServiceClient) DeleteNote(ctx context.Context, req *connect.Request[v1.DeleteNoteRequest]) (*connect.Response[v1.DeleteNoteResponse], error) { + return c.deleteNote.CallUnary(ctx, req) +} + +// NoteServiceHandler is an implementation of the notes.v1.NoteService service. +type NoteServiceHandler interface { + // ListNotes returns the org feed newest-first with keyset + // pagination. An empty next_page_token means the feed is + // exhausted. + ListNotes(context.Context, *connect.Request[v1.ListNotesRequest]) (*connect.Response[v1.ListNotesResponse], error) + // CreateNote appends a note authored by the caller. Content is + // trimmed server-side and must be non-empty after trimming. + CreateNote(context.Context, *connect.Request[v1.CreateNoteRequest]) (*connect.Response[v1.CreateNoteResponse], error) + // UpdateNote edits the content of the caller's own note. Editing + // another author's note is rejected regardless of role. + UpdateNote(context.Context, *connect.Request[v1.UpdateNoteRequest]) (*connect.Response[v1.UpdateNoteResponse], error) + // DeleteNote soft-deletes the caller's own note, or any note when + // the caller holds note:manage. + DeleteNote(context.Context, *connect.Request[v1.DeleteNoteRequest]) (*connect.Response[v1.DeleteNoteResponse], error) +} + +// NewNoteServiceHandler builds an HTTP handler from the service implementation. It returns the path +// on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewNoteServiceHandler(svc NoteServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + noteServiceMethods := v1.File_notes_v1_notes_proto.Services().ByName("NoteService").Methods() + noteServiceListNotesHandler := connect.NewUnaryHandler( + NoteServiceListNotesProcedure, + svc.ListNotes, + connect.WithSchema(noteServiceMethods.ByName("ListNotes")), + connect.WithHandlerOptions(opts...), + ) + noteServiceCreateNoteHandler := connect.NewUnaryHandler( + NoteServiceCreateNoteProcedure, + svc.CreateNote, + connect.WithSchema(noteServiceMethods.ByName("CreateNote")), + connect.WithHandlerOptions(opts...), + ) + noteServiceUpdateNoteHandler := connect.NewUnaryHandler( + NoteServiceUpdateNoteProcedure, + svc.UpdateNote, + connect.WithSchema(noteServiceMethods.ByName("UpdateNote")), + connect.WithHandlerOptions(opts...), + ) + noteServiceDeleteNoteHandler := connect.NewUnaryHandler( + NoteServiceDeleteNoteProcedure, + svc.DeleteNote, + connect.WithSchema(noteServiceMethods.ByName("DeleteNote")), + connect.WithHandlerOptions(opts...), + ) + return "/notes.v1.NoteService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case NoteServiceListNotesProcedure: + noteServiceListNotesHandler.ServeHTTP(w, r) + case NoteServiceCreateNoteProcedure: + noteServiceCreateNoteHandler.ServeHTTP(w, r) + case NoteServiceUpdateNoteProcedure: + noteServiceUpdateNoteHandler.ServeHTTP(w, r) + case NoteServiceDeleteNoteProcedure: + noteServiceDeleteNoteHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedNoteServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedNoteServiceHandler struct{} + +func (UnimplementedNoteServiceHandler) ListNotes(context.Context, *connect.Request[v1.ListNotesRequest]) (*connect.Response[v1.ListNotesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("notes.v1.NoteService.ListNotes is not implemented")) +} + +func (UnimplementedNoteServiceHandler) CreateNote(context.Context, *connect.Request[v1.CreateNoteRequest]) (*connect.Response[v1.CreateNoteResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("notes.v1.NoteService.CreateNote is not implemented")) +} + +func (UnimplementedNoteServiceHandler) UpdateNote(context.Context, *connect.Request[v1.UpdateNoteRequest]) (*connect.Response[v1.UpdateNoteResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("notes.v1.NoteService.UpdateNote is not implemented")) +} + +func (UnimplementedNoteServiceHandler) DeleteNote(context.Context, *connect.Request[v1.DeleteNoteRequest]) (*connect.Response[v1.DeleteNoteResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("notes.v1.NoteService.DeleteNote is not implemented")) +} diff --git a/server/generated/sqlc/db.go b/server/generated/sqlc/db.go index a767fc4bda..6e4b2aea27 100644 --- a/server/generated/sqlc/db.go +++ b/server/generated/sqlc/db.go @@ -144,6 +144,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.createFleetNodeApiKeyStmt, err = db.PrepareContext(ctx, createFleetNodeApiKey); err != nil { return nil, fmt.Errorf("error preparing query CreateFleetNodeApiKey: %w", err) } + if q.createNoteStmt, err = db.PrepareContext(ctx, createNote); err != nil { + return nil, fmt.Errorf("error preparing query CreateNote: %w", err) + } if q.createOrganizationStmt, err = db.PrepareContext(ctx, createOrganization); err != nil { return nil, fmt.Errorf("error preparing query CreateOrganization: %w", err) } @@ -456,6 +459,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.getMinerStateSnapshotsStmt, err = db.PrepareContext(ctx, getMinerStateSnapshots); err != nil { return nil, fmt.Errorf("error preparing query GetMinerStateSnapshots: %w", err) } + if q.getNoteStmt, err = db.PrepareContext(ctx, getNote); err != nil { + return nil, fmt.Errorf("error preparing query GetNote: %w", err) + } if q.getOfflineDevicesStmt, err = db.PrepareContext(ctx, getOfflineDevices); err != nil { return nil, fmt.Errorf("error preparing query GetOfflineDevices: %w", err) } @@ -705,6 +711,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.listNonTerminalCurtailmentEventsStmt, err = db.PrepareContext(ctx, listNonTerminalCurtailmentEvents); err != nil { return nil, fmt.Errorf("error preparing query ListNonTerminalCurtailmentEvents: %w", err) } + if q.listNotesStmt, err = db.PrepareContext(ctx, listNotes); err != nil { + return nil, fmt.Errorf("error preparing query ListNotes: %w", err) + } if q.listOrganizationsStmt, err = db.PrepareContext(ctx, listOrganizations); err != nil { return nil, fmt.Errorf("error preparing query ListOrganizations: %w", err) } @@ -912,6 +921,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.softDeleteFleetNodesForExpiredEnrollmentsStmt, err = db.PrepareContext(ctx, softDeleteFleetNodesForExpiredEnrollments); err != nil { return nil, fmt.Errorf("error preparing query SoftDeleteFleetNodesForExpiredEnrollments: %w", err) } + if q.softDeleteNoteStmt, err = db.PrepareContext(ctx, softDeleteNote); err != nil { + return nil, fmt.Errorf("error preparing query SoftDeleteNote: %w", err) + } if q.softDeleteOrganizationStmt, err = db.PrepareContext(ctx, softDeleteOrganization); err != nil { return nil, fmt.Errorf("error preparing query SoftDeleteOrganization: %w", err) } @@ -1041,6 +1053,9 @@ func Prepare(ctx context.Context, db DBTX) (*Queries, error) { if q.updateMinerPasswordStmt, err = db.PrepareContext(ctx, updateMinerPassword); err != nil { return nil, fmt.Errorf("error preparing query UpdateMinerPassword: %w", err) } + if q.updateNoteContentStmt, err = db.PrepareContext(ctx, updateNoteContent); err != nil { + return nil, fmt.Errorf("error preparing query UpdateNoteContent: %w", err) + } if q.updateOpenErrorStmt, err = db.PrepareContext(ctx, updateOpenError); err != nil { return nil, fmt.Errorf("error preparing query UpdateOpenError: %w", err) } @@ -1327,6 +1342,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing createFleetNodeApiKeyStmt: %w", cerr) } } + if q.createNoteStmt != nil { + if cerr := q.createNoteStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing createNoteStmt: %w", cerr) + } + } if q.createOrganizationStmt != nil { if cerr := q.createOrganizationStmt.Close(); cerr != nil { err = fmt.Errorf("error closing createOrganizationStmt: %w", cerr) @@ -1847,6 +1867,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing getMinerStateSnapshotsStmt: %w", cerr) } } + if q.getNoteStmt != nil { + if cerr := q.getNoteStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing getNoteStmt: %w", cerr) + } + } if q.getOfflineDevicesStmt != nil { if cerr := q.getOfflineDevicesStmt.Close(); cerr != nil { err = fmt.Errorf("error closing getOfflineDevicesStmt: %w", cerr) @@ -2262,6 +2287,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing listNonTerminalCurtailmentEventsStmt: %w", cerr) } } + if q.listNotesStmt != nil { + if cerr := q.listNotesStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing listNotesStmt: %w", cerr) + } + } if q.listOrganizationsStmt != nil { if cerr := q.listOrganizationsStmt.Close(); cerr != nil { err = fmt.Errorf("error closing listOrganizationsStmt: %w", cerr) @@ -2607,6 +2637,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing softDeleteFleetNodesForExpiredEnrollmentsStmt: %w", cerr) } } + if q.softDeleteNoteStmt != nil { + if cerr := q.softDeleteNoteStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing softDeleteNoteStmt: %w", cerr) + } + } if q.softDeleteOrganizationStmt != nil { if cerr := q.softDeleteOrganizationStmt.Close(); cerr != nil { err = fmt.Errorf("error closing softDeleteOrganizationStmt: %w", cerr) @@ -2822,6 +2857,11 @@ func (q *Queries) Close() error { err = fmt.Errorf("error closing updateMinerPasswordStmt: %w", cerr) } } + if q.updateNoteContentStmt != nil { + if cerr := q.updateNoteContentStmt.Close(); cerr != nil { + err = fmt.Errorf("error closing updateNoteContentStmt: %w", cerr) + } + } if q.updateOpenErrorStmt != nil { if cerr := q.updateOpenErrorStmt.Close(); cerr != nil { err = fmt.Errorf("error closing updateOpenErrorStmt: %w", cerr) @@ -3036,6 +3076,7 @@ type Queries struct { createDeviceSetStmt *sql.Stmt createFleetNodeStmt *sql.Stmt createFleetNodeApiKeyStmt *sql.Stmt + createNoteStmt *sql.Stmt createOrganizationStmt *sql.Stmt createPendingEnrollmentStmt *sql.Stmt createPoolStmt *sql.Stmt @@ -3140,6 +3181,7 @@ type Queries struct { getMinerModelGroupsStmt *sql.Stmt getMinerStateCountsByDeviceIDsStmt *sql.Stmt getMinerStateSnapshotsStmt *sql.Stmt + getNoteStmt *sql.Stmt getOfflineDevicesStmt *sql.Stmt getOpenErrorByDedupKeyStmt *sql.Stmt getOrgScopeAssignmentForUserStmt *sql.Stmt @@ -3223,6 +3265,7 @@ type Queries struct { listMQTTSourceStatesByOrgStmt *sql.Stmt listMinerStateSnapshotsStmt *sql.Stmt listNonTerminalCurtailmentEventsStmt *sql.Stmt + listNotesStmt *sql.Stmt listOrganizationsStmt *sql.Stmt listPermissionsStmt *sql.Stmt listPoolsStmt *sql.Stmt @@ -3292,6 +3335,7 @@ type Queries struct { softDeleteDiscoveredDevicesForDeletedDevicesStmt *sql.Stmt softDeleteFleetNodeStmt *sql.Stmt softDeleteFleetNodesForExpiredEnrollmentsStmt *sql.Stmt + softDeleteNoteStmt *sql.Stmt softDeleteOrganizationStmt *sql.Stmt softDeletePoolStmt *sql.Stmt softDeleteRoleStmt *sql.Stmt @@ -3335,6 +3379,7 @@ type Queries struct { updateMessagePermanentlyFailedStmt *sql.Stmt updateMessageStatusStmt *sql.Stmt updateMinerPasswordStmt *sql.Stmt + updateNoteContentStmt *sql.Stmt updateOpenErrorStmt *sql.Stmt updateOrganizationStmt *sql.Stmt updatePoolStmt *sql.Stmt @@ -3408,6 +3453,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { createDeviceSetStmt: q.createDeviceSetStmt, createFleetNodeStmt: q.createFleetNodeStmt, createFleetNodeApiKeyStmt: q.createFleetNodeApiKeyStmt, + createNoteStmt: q.createNoteStmt, createOrganizationStmt: q.createOrganizationStmt, createPendingEnrollmentStmt: q.createPendingEnrollmentStmt, createPoolStmt: q.createPoolStmt, @@ -3512,6 +3558,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { getMinerModelGroupsStmt: q.getMinerModelGroupsStmt, getMinerStateCountsByDeviceIDsStmt: q.getMinerStateCountsByDeviceIDsStmt, getMinerStateSnapshotsStmt: q.getMinerStateSnapshotsStmt, + getNoteStmt: q.getNoteStmt, getOfflineDevicesStmt: q.getOfflineDevicesStmt, getOpenErrorByDedupKeyStmt: q.getOpenErrorByDedupKeyStmt, getOrgScopeAssignmentForUserStmt: q.getOrgScopeAssignmentForUserStmt, @@ -3595,6 +3642,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { listMQTTSourceStatesByOrgStmt: q.listMQTTSourceStatesByOrgStmt, listMinerStateSnapshotsStmt: q.listMinerStateSnapshotsStmt, listNonTerminalCurtailmentEventsStmt: q.listNonTerminalCurtailmentEventsStmt, + listNotesStmt: q.listNotesStmt, listOrganizationsStmt: q.listOrganizationsStmt, listPermissionsStmt: q.listPermissionsStmt, listPoolsStmt: q.listPoolsStmt, @@ -3664,6 +3712,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { softDeleteDiscoveredDevicesForDeletedDevicesStmt: q.softDeleteDiscoveredDevicesForDeletedDevicesStmt, softDeleteFleetNodeStmt: q.softDeleteFleetNodeStmt, softDeleteFleetNodesForExpiredEnrollmentsStmt: q.softDeleteFleetNodesForExpiredEnrollmentsStmt, + softDeleteNoteStmt: q.softDeleteNoteStmt, softDeleteOrganizationStmt: q.softDeleteOrganizationStmt, softDeletePoolStmt: q.softDeletePoolStmt, softDeleteRoleStmt: q.softDeleteRoleStmt, @@ -3707,6 +3756,7 @@ func (q *Queries) WithTx(tx *sql.Tx) *Queries { updateMessagePermanentlyFailedStmt: q.updateMessagePermanentlyFailedStmt, updateMessageStatusStmt: q.updateMessageStatusStmt, updateMinerPasswordStmt: q.updateMinerPasswordStmt, + updateNoteContentStmt: q.updateNoteContentStmt, updateOpenErrorStmt: q.updateOpenErrorStmt, updateOrganizationStmt: q.updateOrganizationStmt, updatePoolStmt: q.updatePoolStmt, diff --git a/server/generated/sqlc/models.go b/server/generated/sqlc/models.go index 597a452415..7ef345f07d 100644 --- a/server/generated/sqlc/models.go +++ b/server/generated/sqlc/models.go @@ -806,6 +806,16 @@ type MinerStateSnapshot struct { SiteID sql.NullInt64 } +type Note struct { + ID int64 + OrgID int64 + UserID int64 + Content string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt sql.NullTime +} + type NotificationHistory struct { ID int64 ReceivedAt time.Time diff --git a/server/generated/sqlc/note.sql.go b/server/generated/sqlc/note.sql.go new file mode 100644 index 0000000000..06dc2a27c7 --- /dev/null +++ b/server/generated/sqlc/note.sql.go @@ -0,0 +1,203 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: note.sql + +package sqlc + +import ( + "context" + "database/sql" + "time" +) + +const createNote = `-- name: CreateNote :one +INSERT INTO note (org_id, user_id, content) +VALUES ($1, $2, $3) +RETURNING id, org_id, user_id, content, created_at, updated_at, deleted_at +` + +type CreateNoteParams struct { + OrgID int64 + UserID int64 + Content string +} + +func (q *Queries) CreateNote(ctx context.Context, arg CreateNoteParams) (Note, error) { + row := q.queryRow(ctx, q.createNoteStmt, createNote, arg.OrgID, arg.UserID, arg.Content) + var i Note + err := row.Scan( + &i.ID, + &i.OrgID, + &i.UserID, + &i.Content, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const getNote = `-- name: GetNote :one +SELECT id, org_id, user_id, content, created_at, updated_at, deleted_at +FROM note +WHERE id = $1 + AND org_id = $2 + AND deleted_at IS NULL +` + +type GetNoteParams struct { + ID int64 + OrgID int64 +} + +func (q *Queries) GetNote(ctx context.Context, arg GetNoteParams) (Note, error) { + row := q.queryRow(ctx, q.getNoteStmt, getNote, arg.ID, arg.OrgID) + var i Note + err := row.Scan( + &i.ID, + &i.OrgID, + &i.UserID, + &i.Content, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} + +const listNotes = `-- name: ListNotes :many +SELECT + n.id, + n.org_id, + n.user_id, + u.username AS author_username, + n.content, + n.created_at, + n.updated_at +FROM note n +JOIN "user" u ON u.id = n.user_id +WHERE n.org_id = $1 + AND n.deleted_at IS NULL + AND ($2::timestamptz IS NULL + OR (n.created_at, n.id) < ($2::timestamptz, $3::bigint)) +ORDER BY n.created_at DESC, n.id DESC +LIMIT $4 +` + +type ListNotesParams struct { + OrgID int64 + CursorTime sql.NullTime + CursorID sql.NullInt64 + PageSize int32 +} + +type ListNotesRow struct { + ID int64 + OrgID int64 + UserID int64 + AuthorUsername string + Content string + CreatedAt time.Time + UpdatedAt time.Time +} + +// Keyset pagination mirrors activity.sql: strict (created_at, id) tuple +// descent, newest first. The "user" join supplies the display username; +// it deliberately ignores user.deleted_at so a deactivated author still +// attributes. +func (q *Queries) ListNotes(ctx context.Context, arg ListNotesParams) ([]ListNotesRow, error) { + rows, err := q.query(ctx, q.listNotesStmt, listNotes, + arg.OrgID, + arg.CursorTime, + arg.CursorID, + arg.PageSize, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListNotesRow + for rows.Next() { + var i ListNotesRow + if err := rows.Scan( + &i.ID, + &i.OrgID, + &i.UserID, + &i.AuthorUsername, + &i.Content, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const softDeleteNote = `-- name: SoftDeleteNote :execrows +UPDATE note +SET deleted_at = CURRENT_TIMESTAMP +WHERE id = $1 + AND org_id = $2 + AND deleted_at IS NULL +` + +type SoftDeleteNoteParams struct { + ID int64 + OrgID int64 +} + +func (q *Queries) SoftDeleteNote(ctx context.Context, arg SoftDeleteNoteParams) (int64, error) { + result, err := q.exec(ctx, q.softDeleteNoteStmt, softDeleteNote, arg.ID, arg.OrgID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const updateNoteContent = `-- name: UpdateNoteContent :one +UPDATE note +SET content = $1 +WHERE id = $2 + AND org_id = $3 + AND user_id = $4 + AND deleted_at IS NULL +RETURNING id, org_id, user_id, content, created_at, updated_at, deleted_at +` + +type UpdateNoteContentParams struct { + Content string + ID int64 + OrgID int64 + UserID int64 +} + +// The author predicate lives in the WHERE so the ownership check cannot +// race the domain layer's read; zero rows maps to NotFound at the store. +func (q *Queries) UpdateNoteContent(ctx context.Context, arg UpdateNoteContentParams) (Note, error) { + row := q.queryRow(ctx, q.updateNoteContentStmt, updateNoteContent, + arg.Content, + arg.ID, + arg.OrgID, + arg.UserID, + ) + var i Note + err := row.Scan( + &i.ID, + &i.OrgID, + &i.UserID, + &i.Content, + &i.CreatedAt, + &i.UpdatedAt, + &i.DeletedAt, + ) + return i, err +} diff --git a/server/internal/domain/activity/models/models.go b/server/internal/domain/activity/models/models.go index 0e13c39bcf..1a39434019 100644 --- a/server/internal/domain/activity/models/models.go +++ b/server/internal/domain/activity/models/models.go @@ -15,6 +15,7 @@ const ( CategoryPool EventCategory = "pool" CategorySchedule EventCategory = "schedule" CategoryCurtailment EventCategory = "curtailment" + CategoryNote EventCategory = "note" CategorySystem EventCategory = "system" ) @@ -38,7 +39,7 @@ func (c EventCategory) Valid() bool { switch c { case CategoryAuth, CategoryDeviceCommand, CategoryFleetManagement, CategoryCollection, CategoryPool, CategorySchedule, - CategoryCurtailment, CategorySystem: + CategoryCurtailment, CategoryNote, CategorySystem: return true } return false diff --git a/server/internal/domain/activity/models/models_test.go b/server/internal/domain/activity/models/models_test.go index d4ed4035f1..866469fa74 100644 --- a/server/internal/domain/activity/models/models_test.go +++ b/server/internal/domain/activity/models/models_test.go @@ -20,6 +20,7 @@ func TestEventCategoryValid(t *testing.T) { {CategoryPool, true}, {CategorySchedule, true}, {CategoryCurtailment, true}, + {CategoryNote, true}, {CategorySystem, true}, {EventCategory(""), false}, {EventCategory("unknown"), false}, diff --git a/server/internal/domain/authz/builtin.go b/server/internal/domain/authz/builtin.go index 27aaa38739..556892d1f8 100644 --- a/server/internal/domain/authz/builtin.go +++ b/server/internal/domain/authz/builtin.go @@ -71,7 +71,7 @@ func BuiltinRoles() []BuiltinRoleSpec { { Key: BuiltinKeyFieldTech, Name: "FIELD_TECH", - Description: "Field tech. Read fleet data, blink the locator LED, download logs, manage racks. Built-in role; cannot be modified.", + Description: "Field tech. Read fleet data, blink the locator LED, download logs, manage racks, read and post team notes. Built-in role; cannot be modified.", Mode: ReconcileAdditive, SeedPermissions: fieldTechSeedPermissions(), }, @@ -121,5 +121,7 @@ func fieldTechSeedPermissions() []string { PermMinerDownloadLogs, PermRackRead, PermRackManage, + PermNoteRead, + PermNoteCreate, } } diff --git a/server/internal/domain/authz/catalog.go b/server/internal/domain/authz/catalog.go index 906a675bab..56bc9f80e9 100644 --- a/server/internal/domain/authz/catalog.go +++ b/server/internal/domain/authz/catalog.go @@ -85,6 +85,13 @@ const ( // role — custom role + ADMIN/FIELD_TECH editing. PermRoleManage = "role:manage" + + // note — the shared team notepad: an org-wide feed every member can + // read and post to. note:manage is the moderation key (delete any + // note); editing stays author-only regardless of role. + PermNoteRead = "note:read" + PermNoteCreate = "note:create" + PermNoteManage = "note:manage" ) // Resource identifiers used to group catalog entries for the admin UI @@ -104,6 +111,7 @@ const ( ResourceAPIKey = "apikey" ResourceUser = "user" ResourceRole = "role" + ResourceNote = "note" ) // CatalogEntry is the in-code shape of a single permission. The wire-level @@ -169,6 +177,10 @@ var catalog = []CatalogEntry{ {PermUserManage, "Create, reset, and deactivate users in the organization.", ResourceUser}, {PermRoleManage, "Create, edit, and delete custom roles. Built-in roles cannot be modified.", ResourceRole}, + + {PermNoteRead, "View the shared team notepad.", ResourceNote}, + {PermNoteCreate, "Add notes to the shared team notepad and edit or delete your own notes.", ResourceNote}, + {PermNoteManage, "Delete any note on the shared team notepad.", ResourceNote}, } // AllPermissions returns the canonical permission keys in catalog order. The diff --git a/server/internal/domain/authz/catalog_test.go b/server/internal/domain/authz/catalog_test.go index 728c7b061c..7cc8cebf2a 100644 --- a/server/internal/domain/authz/catalog_test.go +++ b/server/internal/domain/authz/catalog_test.go @@ -71,6 +71,9 @@ func TestCatalogCompleteness(t *testing.T) { PermUserRead, PermUserManage, PermRoleManage, + PermNoteRead, + PermNoteCreate, + PermNoteManage, } for _, key := range expectedKeys { @@ -149,6 +152,7 @@ func TestResourceOrder_MatchesCatalogDeclarationOrder(t *testing.T) { ResourceAPIKey, ResourceUser, ResourceRole, + ResourceNote, } if !reflect.DeepEqual(got, want) { t.Errorf("ResourceOrder mismatch:\n got: %v\n want: %v", got, want) diff --git a/server/internal/domain/authz/effective.go b/server/internal/domain/authz/effective.go index 6a51865d0d..064bd62795 100644 --- a/server/internal/domain/authz/effective.go +++ b/server/internal/domain/authz/effective.go @@ -112,6 +112,31 @@ func (e *EffectivePermissions) Has(key string, rc ResourceContext) bool { return e.orgScope[key] } +// HasAnywhere reports whether the user holds the key under ANY +// assignment in this org — org scope or any site bucket. It is the +// decision-time gate for org-shared collaborative resources (the team +// notepad) where a role assignment at any scope makes the caller a +// member of the surface the resource serves; such resources have no +// site dimension, so narrowing has nothing to act on and union +// semantics are deliberate. Membership matches FlatKeys(), which is +// what UserInfo.permissions projects for the client's coarse gating — +// gating the server on HasAnywhere keeps both sides of that contract +// in agreement. +func (e *EffectivePermissions) HasAnywhere(key string) bool { + if e == nil { + return false + } + if e.orgScope[key] { + return true + } + for _, siteKeys := range e.bySite { + if siteKeys[key] { + return true + } + } + return false +} + // StrictlyDominates reports whether this EffectivePermissions // subsumes other AND holds at least one (key, scope) pair other does // not — i.e., a proper superset. Used as the no-role:manage branch of diff --git a/server/internal/domain/authz/effective_test.go b/server/internal/domain/authz/effective_test.go index b5e2666a79..fe367ed8c5 100644 --- a/server/internal/domain/authz/effective_test.go +++ b/server/internal/domain/authz/effective_test.go @@ -248,3 +248,74 @@ func TestEffective_IsSubsumedBy(t *testing.T) { require.False(t, adminOrg.IsSubsumedBy(callerOrgFullPlusNarrowSite7), "caller's site-7 narrowing strips miner:reboot, but the org-scope ADMIN target's miner:reboot authority remains live at site 7 — caller must not subsume target") } + +func TestEffective_HasAnywhere_OrgScopeGrant(t *testing.T) { + eff := authz.NewEffectivePermissions([]authz.Assignment{ + orgScope(authz.PermNoteRead), + }) + require.True(t, eff.HasAnywhere(authz.PermNoteRead)) +} + +func TestEffective_HasAnywhere_SiteScopeOnlyGrant(t *testing.T) { + // The regression target: a FIELD_TECH whose only assignment is + // site-scoped must still pass the notepad gate, even though Has() + // with an org resource context denies (no org-scope grant). + eff := authz.NewEffectivePermissions([]authz.Assignment{ + siteScope(1, authz.PermNoteRead, authz.PermNoteCreate), + }) + + require.False(t, eff.Has(authz.PermNoteRead, orgResource()), + "sanity: an org-scoped Has() denies a site-only grant") + require.True(t, eff.HasAnywhere(authz.PermNoteRead)) + require.True(t, eff.HasAnywhere(authz.PermNoteCreate)) +} + +func TestEffective_HasAnywhere_AbsentKeyDenied(t *testing.T) { + eff := authz.NewEffectivePermissions([]authz.Assignment{ + orgScope(authz.PermFleetRead), + siteScope(1, authz.PermMinerRead), + }) + require.False(t, eff.HasAnywhere(authz.PermNoteRead)) +} + +func TestEffective_HasAnywhere_NilAndEmptyDeny(t *testing.T) { + var nilEff *authz.EffectivePermissions + require.False(t, nilEff.HasAnywhere(authz.PermNoteRead)) + require.False(t, authz.NewEffectivePermissions(nil).HasAnywhere(authz.PermNoteRead)) +} + +func TestEffective_HasAnywhere_NarrowedOrgKeyStillCountsAnywhere(t *testing.T) { + // Union semantics, documented on purpose: a site assignment that + // narrows away note:read at site 1 still leaves the org-scope grant + // counting for HasAnywhere — the notepad has no site dimension, so + // narrowing has nothing to act on. + eff := authz.NewEffectivePermissions([]authz.Assignment{ + orgScope(authz.PermNoteRead), + siteScope(1, authz.PermMinerRead), // no note:read at site 1 + }) + + require.False(t, eff.Has(authz.PermNoteRead, site(1)), + "sanity: narrowing strips the key at site 1 for site-scoped checks") + require.True(t, eff.HasAnywhere(authz.PermNoteRead)) +} + +func TestEffective_HasAnywhere_MatchesFlatKeys(t *testing.T) { + // HasAnywhere is the decision-time twin of FlatKeys(), which is + // what UserInfo.permissions projects for the client's coarse + // gating. The two must agree so client visibility and server + // authorization can't drift. + eff := authz.NewEffectivePermissions([]authz.Assignment{ + orgScope(authz.PermFleetRead), + siteScope(3, authz.PermNoteRead, authz.PermNoteCreate), + }) + + flat := make(map[string]bool) + for _, k := range eff.FlatKeys() { + flat[k] = true + } + for _, key := range []string{ + authz.PermFleetRead, authz.PermNoteRead, authz.PermNoteCreate, authz.PermNoteManage, + } { + require.Equal(t, flat[key], eff.HasAnywhere(key), "FlatKeys/HasAnywhere parity for %s", key) + } +} diff --git a/server/internal/domain/authz/reconcile_test.go b/server/internal/domain/authz/reconcile_test.go index 64d0d23f19..c8b1e7edcb 100644 --- a/server/internal/domain/authz/reconcile_test.go +++ b/server/internal/domain/authz/reconcile_test.go @@ -59,6 +59,8 @@ func TestReconcile_FreshInstall_AdminExcludesRoleManagement(t *testing.T) { require.Contains(t, got, authz.PermUserRead, "ADMIN holds user:read so org admins can view the team roster") require.Contains(t, got, authz.PermUserManage, "ADMIN holds user:manage; hierarchy check blocks elevated targets at the domain layer") require.Contains(t, got, authz.PermMinerReboot, "ADMIN should still hold miner action permissions") + require.Subset(t, got, []string{authz.PermNoteRead, authz.PermNoteCreate, authz.PermNoteManage}, + "ADMIN seeds with the full notepad permission set, including moderation") } func TestReconcile_FreshInstall_FieldTechHasExactSeedSet(t *testing.T) { @@ -75,6 +77,8 @@ func TestReconcile_FreshInstall_FieldTechHasExactSeedSet(t *testing.T) { authz.PermMinerRead, authz.PermRackManage, authz.PermRackRead, + authz.PermNoteRead, + authz.PermNoteCreate, } sort.Strings(want) require.Equal(t, want, got) diff --git a/server/internal/domain/authz/service_test.go b/server/internal/domain/authz/service_test.go index e14995b045..efd01ce0ff 100644 --- a/server/internal/domain/authz/service_test.go +++ b/server/internal/domain/authz/service_test.go @@ -128,6 +128,22 @@ func TestValidateReadPairing(t *testing.T) { t.Fatalf("combined manage-only permissions should not require partners: %v", err) } }) + t.Run("note actions need note:read", func(t *testing.T) { + err := validateReadPairing([]string{PermNoteCreate}) + if err == nil { + t.Fatal("expected error for missing note:read") + } + if !strings.Contains(err.Error(), PermNoteRead) { + t.Fatalf("error should mention note:read: %v", err) + } + err = validateReadPairing([]string{PermNoteManage}) + if err == nil { + t.Fatal("expected error for missing note:read") + } + if err := validateReadPairing([]string{PermNoteCreate, PermNoteManage, PermNoteRead}); err != nil { + t.Fatalf("note actions with note:read should pass: %v", err) + } + }) } func TestMapRolePersistError(t *testing.T) { diff --git a/server/internal/domain/notes/models/models.go b/server/internal/domain/notes/models/models.go new file mode 100644 index 0000000000..25700eaaf3 --- /dev/null +++ b/server/internal/domain/notes/models/models.go @@ -0,0 +1,44 @@ +// Package models holds the domain shapes for the shared team notepad: +// one org-wide feed of notes every member can read and post to. +package models + +import "time" + +const ( + // DefaultPageSize is applied when a list request carries no page + // size (e.g. an internal caller passing the zero value). + DefaultPageSize = 25 + + // MaxPageSize caps a single ListNotes page. Mirrors the activity + // log's wire-level lte:100 validation. + MaxPageSize = 100 + + // MaxContentRunes caps note content after trimming. The proto + // annotation enforces the same number of codepoints pre-trim; the + // domain recheck is authoritative for the stored value. + MaxContentRunes = 4096 +) + +// Note is one entry in the org's shared notepad. AuthorUsername is a +// read-time projection from the "user" table (or stamped from the +// session on create/update); it is display attribution only — the +// author-only edit/delete rule keys on UserID. +type Note struct { + ID int64 + OrgID int64 + UserID int64 + AuthorUsername string + Content string + CreatedAt time.Time + UpdatedAt time.Time +} + +// ListNotesParams is the domain-level input for one feed page. A nil +// cursor pair means "first page"; both cursor fields are set together +// from the previous page's last row. +type ListNotesParams struct { + OrgID int64 + PageSize int32 + CursorTime *time.Time + CursorID *int64 +} diff --git a/server/internal/domain/notes/service.go b/server/internal/domain/notes/service.go new file mode 100644 index 0000000000..303e72b591 --- /dev/null +++ b/server/internal/domain/notes/service.go @@ -0,0 +1,169 @@ +// Package notes implements the shared team notepad: one org-wide, +// append-style feed of notes every member of the org can read and +// post to. Authors edit and delete their own notes; moderation +// (deleting another author's note) is a separate capability the +// handler resolves and passes in. +package notes + +import ( + "context" + "fmt" + "strings" + "unicode/utf8" + + "github.com/block/proto-fleet/server/internal/domain/activity" + activitymodels "github.com/block/proto-fleet/server/internal/domain/activity/models" + "github.com/block/proto-fleet/server/internal/domain/fleeterror" + "github.com/block/proto-fleet/server/internal/domain/notes/models" + "github.com/block/proto-fleet/server/internal/domain/stores/interfaces" +) + +// Event types recorded on the org activity log for notepad mutations. +const ( + eventNoteCreated = "note.created" + eventNoteUpdated = "note.updated" + eventNoteDeleted = "note.deleted" +) + +// Service owns the notepad's business rules: content normalization, +// the author-only edit rule, and the author-or-moderator delete rule. +// Tenancy (org scoping) is enforced by the store's SQL predicates. +type Service struct { + store interfaces.NoteStore + activitySvc *activity.Service +} + +// NewService wires the notepad domain service. activitySvc may be nil +// (Log is nil-safe), which test harnesses use to skip audit writes. +func NewService(store interfaces.NoteStore, activitySvc *activity.Service) *Service { + return &Service{store: store, activitySvc: activitySvc} +} + +// ListNotes returns one feed page, newest first. PageSize is clamped +// to [1, MaxPageSize]; the zero value selects DefaultPageSize so +// internal callers can pass a zero-valued params struct safely. +func (s *Service) ListNotes(ctx context.Context, params models.ListNotesParams) ([]models.Note, error) { + if params.PageSize <= 0 { + params.PageSize = models.DefaultPageSize + } + if params.PageSize > models.MaxPageSize { + params.PageSize = models.MaxPageSize + } + return s.store.ListNotes(ctx, params) +} + +// CreateNote appends a note authored by the caller and returns it +// with the author's username stamped for immediate display. +func (s *Service) CreateNote(ctx context.Context, orgID, authorUserID int64, authorUsername, content string) (*models.Note, error) { + content, err := normalizeContent(content) + if err != nil { + return nil, err + } + + note, err := s.store.CreateNote(ctx, orgID, authorUserID, content) + if err != nil { + return nil, err + } + note.AuthorUsername = authorUsername + + s.logEvent(ctx, orgID, eventNoteCreated, + fmt.Sprintf("Added a team note (id=%d)", note.ID), + map[string]any{ + "note_id": note.ID, + "content_length": utf8.RuneCountInString(content), + }) + return note, nil +} + +// UpdateNote replaces the content of the caller's own note. Editing +// another author's note is Forbidden regardless of role — moderation +// covers deletion only, so a note's content always reflects its +// author's words. +func (s *Service) UpdateNote(ctx context.Context, orgID, noteID, callerUserID int64, callerUsername, content string) (*models.Note, error) { + content, err := normalizeContent(content) + if err != nil { + return nil, err + } + + existing, err := s.store.GetNote(ctx, orgID, noteID) + if err != nil { + return nil, err + } + if existing.UserID != callerUserID { + return nil, fleeterror.NewForbiddenError("only the author can edit a note") + } + + // The store repeats the ownership predicate in the UPDATE's WHERE, + // so a concurrent delete/re-author cannot slip between the read + // above and this write. + note, err := s.store.UpdateNoteContent(ctx, orgID, noteID, callerUserID, content) + if err != nil { + return nil, err + } + note.AuthorUsername = callerUsername + + s.logEvent(ctx, orgID, eventNoteUpdated, + fmt.Sprintf("Edited a team note (id=%d)", note.ID), + map[string]any{ + "note_id": note.ID, + "content_length": utf8.RuneCountInString(content), + }) + return note, nil +} + +// DeleteNote soft-deletes a note. The caller must be the note's +// author, or canModerateAnyNote must be true (the handler resolves it +// from the note:manage permission). +func (s *Service) DeleteNote(ctx context.Context, orgID, noteID, callerUserID int64, canModerateAnyNote bool) error { + existing, err := s.store.GetNote(ctx, orgID, noteID) + if err != nil { + return err + } + moderated := existing.UserID != callerUserID + if moderated && !canModerateAnyNote { + return fleeterror.NewForbiddenError("only the author or a notepad moderator can delete a note") + } + + if err := s.store.SoftDeleteNote(ctx, orgID, noteID); err != nil { + return err + } + + s.logEvent(ctx, orgID, eventNoteDeleted, + fmt.Sprintf("Deleted a team note (id=%d)", noteID), + map[string]any{ + "note_id": noteID, + "author_user_id": existing.UserID, + "moderated": moderated, + }) + return nil +} + +// normalizeContent trims surrounding whitespace and validates the +// result. The proto annotation bounds the pre-trim length on the +// wire; this check is authoritative for the stored value. +func normalizeContent(content string) (string, error) { + content = strings.TrimSpace(content) + if content == "" { + return "", fleeterror.NewInvalidArgumentError("note content cannot be empty") + } + if utf8.RuneCountInString(content) > models.MaxContentRunes { + return "", fleeterror.NewInvalidArgumentErrorf("note content exceeds %d characters", models.MaxContentRunes) + } + return content, nil +} + +// logEvent records an org-scoped notepad event on the activity log. +// Note content never enters the audit row — only its length — so the +// activity feed can't become a side channel around a future +// note deletion. +func (s *Service) logEvent(ctx context.Context, orgID int64, eventType, description string, metadata map[string]any) { + event := activitymodels.Event{ + Category: activitymodels.CategoryNote, + Type: eventType, + OrganizationID: &orgID, + Description: description, + Metadata: metadata, + } + activity.StampActor(ctx, &event) + s.activitySvc.Log(ctx, event) +} diff --git a/server/internal/domain/notes/service_test.go b/server/internal/domain/notes/service_test.go new file mode 100644 index 0000000000..94dc69ecd3 --- /dev/null +++ b/server/internal/domain/notes/service_test.go @@ -0,0 +1,175 @@ +package notes + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/block/proto-fleet/server/internal/domain/activity" + activitymodels "github.com/block/proto-fleet/server/internal/domain/activity/models" + "github.com/block/proto-fleet/server/internal/domain/fleeterror" + "github.com/block/proto-fleet/server/internal/domain/notes/models" + "github.com/block/proto-fleet/server/internal/domain/stores/interfaces/mocks" +) + +// newService wires the domain service against a mock note store and a +// mock-backed activity service that records every event for +// assertion. Pass withActivity=false to exercise the nil-activity +// path (handler test harnesses rely on it being a no-op). +func newService(t *testing.T, withActivity bool) (*Service, *mocks.MockNoteStore, *[]activitymodels.Event) { + t.Helper() + ctrl := gomock.NewController(t) + store := mocks.NewMockNoteStore(ctrl) + + events := &[]activitymodels.Event{} + var activitySvc *activity.Service + if withActivity { + activityStore := mocks.NewMockActivityStore(ctrl) + activityStore.EXPECT().Insert(gomock.Any(), gomock.Any()).AnyTimes().DoAndReturn( + func(_ any, e *activitymodels.Event) error { + *events = append(*events, *e) + return nil + }) + activitySvc = activity.NewService(activityStore) + } + return NewService(store, activitySvc), store, events +} + +func TestService_CreateNote_NormalizesContentAndLogs(t *testing.T) { + t.Parallel() + svc, store, events := newService(t, true) + + store.EXPECT().CreateNote(gomock.Any(), int64(1), int64(7), "hello"). + Return(&models.Note{ID: 3, OrgID: 1, UserID: 7, Content: "hello"}, nil) + + note, err := svc.CreateNote(t.Context(), 1, 7, "alice", " hello \n") + require.NoError(t, err) + require.Equal(t, "hello", note.Content, "stored value is the trimmed string") + require.Equal(t, "alice", note.AuthorUsername, "username stamped for immediate display") + + require.Len(t, *events, 1) + got := (*events)[0] + require.Equal(t, activitymodels.CategoryNote, got.Category) + require.Equal(t, "note.created", got.Type) + require.NotContains(t, got.Description, "hello", + "note content must not leak into the audit row") + require.Equal(t, int64(3), got.Metadata["note_id"]) +} + +func TestService_ContentValidationBoundaries(t *testing.T) { + t.Parallel() + svc, store, _ := newService(t, false) + + _, err := svc.CreateNote(t.Context(), 1, 7, "alice", " \t\n ") + requireCodeIs(t, err, fleeterror.NewInvalidArgumentError("").GRPCCode) + + // Exactly MaxContentRunes is accepted (multibyte runes count as one). + atCap := strings.Repeat("ü", models.MaxContentRunes) + store.EXPECT().CreateNote(gomock.Any(), int64(1), int64(7), atCap). + Return(&models.Note{ID: 1, OrgID: 1, UserID: 7, Content: atCap}, nil) + _, err = svc.CreateNote(t.Context(), 1, 7, "alice", atCap) + require.NoError(t, err) + + // One rune over is rejected before the store is touched. + _, err = svc.CreateNote(t.Context(), 1, 7, "alice", atCap+"x") + requireCodeIs(t, err, fleeterror.NewInvalidArgumentError("").GRPCCode) +} + +func TestService_UpdateNote_AuthorOnly(t *testing.T) { + t.Parallel() + svc, store, events := newService(t, true) + + // Non-author: Forbidden, no write, no audit row. + store.EXPECT().GetNote(gomock.Any(), int64(1), int64(5)). + Return(&models.Note{ID: 5, OrgID: 1, UserID: 7}, nil) + _, err := svc.UpdateNote(t.Context(), 1, 5, 8, "mallory", "rewrite") + requireCodeIs(t, err, fleeterror.NewForbiddenError("").GRPCCode) + require.Empty(t, *events) + + // Author: update lands and logs note.updated. + store.EXPECT().GetNote(gomock.Any(), int64(1), int64(5)). + Return(&models.Note{ID: 5, OrgID: 1, UserID: 7}, nil) + store.EXPECT().UpdateNoteContent(gomock.Any(), int64(1), int64(5), int64(7), "better"). + Return(&models.Note{ID: 5, OrgID: 1, UserID: 7, Content: "better"}, nil) + note, err := svc.UpdateNote(t.Context(), 1, 5, 7, "alice", " better ") + require.NoError(t, err) + require.Equal(t, "better", note.Content) + require.Len(t, *events, 1) + require.Equal(t, "note.updated", (*events)[0].Type) +} + +func TestService_DeleteNote_AuthorOrModerator(t *testing.T) { + t.Parallel() + + t.Run("author deletes own; moderated=false", func(t *testing.T) { + t.Parallel() + svc, store, events := newService(t, true) + store.EXPECT().GetNote(gomock.Any(), int64(1), int64(5)). + Return(&models.Note{ID: 5, OrgID: 1, UserID: 7}, nil) + store.EXPECT().SoftDeleteNote(gomock.Any(), int64(1), int64(5)).Return(nil) + + require.NoError(t, svc.DeleteNote(t.Context(), 1, 5, 7, false)) + require.Len(t, *events, 1) + got := (*events)[0] + require.Equal(t, "note.deleted", got.Type) + require.Equal(t, false, got.Metadata["moderated"]) + require.Equal(t, int64(7), got.Metadata["author_user_id"]) + }) + + t.Run("non-author without moderation is Forbidden", func(t *testing.T) { + t.Parallel() + svc, store, _ := newService(t, false) + store.EXPECT().GetNote(gomock.Any(), int64(1), int64(5)). + Return(&models.Note{ID: 5, OrgID: 1, UserID: 7}, nil) + + err := svc.DeleteNote(t.Context(), 1, 5, 8, false) + requireCodeIs(t, err, fleeterror.NewForbiddenError("").GRPCCode) + }) + + t.Run("moderator deletes another author's note; moderated=true", func(t *testing.T) { + t.Parallel() + svc, store, events := newService(t, true) + store.EXPECT().GetNote(gomock.Any(), int64(1), int64(5)). + Return(&models.Note{ID: 5, OrgID: 1, UserID: 7}, nil) + store.EXPECT().SoftDeleteNote(gomock.Any(), int64(1), int64(5)).Return(nil) + + require.NoError(t, svc.DeleteNote(t.Context(), 1, 5, 8, true)) + require.Len(t, *events, 1) + require.Equal(t, true, (*events)[0].Metadata["moderated"]) + }) +} + +func TestService_ListNotes_ClampsPageSize(t *testing.T) { + t.Parallel() + svc, store, _ := newService(t, false) + + store.EXPECT().ListNotes(gomock.Any(), models.ListNotesParams{OrgID: 1, PageSize: models.DefaultPageSize}).Return(nil, nil) + _, err := svc.ListNotes(t.Context(), models.ListNotesParams{OrgID: 1}) + require.NoError(t, err) + + store.EXPECT().ListNotes(gomock.Any(), models.ListNotesParams{OrgID: 1, PageSize: models.MaxPageSize}).Return(nil, nil) + _, err = svc.ListNotes(t.Context(), models.ListNotesParams{OrgID: 1, PageSize: models.MaxPageSize + 50}) + require.NoError(t, err) +} + +func TestService_NilActivityServiceIsNoOp(t *testing.T) { + t.Parallel() + svc, store, _ := newService(t, false) + + store.EXPECT().CreateNote(gomock.Any(), int64(1), int64(7), "hi"). + Return(&models.Note{ID: 1, OrgID: 1, UserID: 7, Content: "hi"}, nil) + + // Must not panic despite activitySvc == nil. + _, err := svc.CreateNote(t.Context(), 1, 7, "alice", "hi") + require.NoError(t, err) +} + +func requireCodeIs(t *testing.T, err error, want any) { + t.Helper() + require.Error(t, err) + var fe fleeterror.FleetError + require.ErrorAs(t, err, &fe) + require.Equal(t, want, fe.GRPCCode) +} diff --git a/server/internal/domain/stores/interfaces/mocks/mock_note_store.go b/server/internal/domain/stores/interfaces/mocks/mock_note_store.go new file mode 100644 index 0000000000..a044ff707a --- /dev/null +++ b/server/internal/domain/stores/interfaces/mocks/mock_note_store.go @@ -0,0 +1,116 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: note.go +// +// Generated by this command: +// +// mockgen -source=note.go -destination=mocks/mock_note_store.go -package=mocks NoteStore +// + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + models "github.com/block/proto-fleet/server/internal/domain/notes/models" + gomock "go.uber.org/mock/gomock" +) + +// MockNoteStore is a mock of NoteStore interface. +type MockNoteStore struct { + ctrl *gomock.Controller + recorder *MockNoteStoreMockRecorder + isgomock struct{} +} + +// MockNoteStoreMockRecorder is the mock recorder for MockNoteStore. +type MockNoteStoreMockRecorder struct { + mock *MockNoteStore +} + +// NewMockNoteStore creates a new mock instance. +func NewMockNoteStore(ctrl *gomock.Controller) *MockNoteStore { + mock := &MockNoteStore{ctrl: ctrl} + mock.recorder = &MockNoteStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockNoteStore) EXPECT() *MockNoteStoreMockRecorder { + return m.recorder +} + +// CreateNote mocks base method. +func (m *MockNoteStore) CreateNote(ctx context.Context, orgID, userID int64, content string) (*models.Note, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateNote", ctx, orgID, userID, content) + ret0, _ := ret[0].(*models.Note) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateNote indicates an expected call of CreateNote. +func (mr *MockNoteStoreMockRecorder) CreateNote(ctx, orgID, userID, content any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateNote", reflect.TypeOf((*MockNoteStore)(nil).CreateNote), ctx, orgID, userID, content) +} + +// GetNote mocks base method. +func (m *MockNoteStore) GetNote(ctx context.Context, orgID, id int64) (*models.Note, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNote", ctx, orgID, id) + ret0, _ := ret[0].(*models.Note) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNote indicates an expected call of GetNote. +func (mr *MockNoteStoreMockRecorder) GetNote(ctx, orgID, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNote", reflect.TypeOf((*MockNoteStore)(nil).GetNote), ctx, orgID, id) +} + +// ListNotes mocks base method. +func (m *MockNoteStore) ListNotes(ctx context.Context, params models.ListNotesParams) ([]models.Note, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListNotes", ctx, params) + ret0, _ := ret[0].([]models.Note) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListNotes indicates an expected call of ListNotes. +func (mr *MockNoteStoreMockRecorder) ListNotes(ctx, params any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListNotes", reflect.TypeOf((*MockNoteStore)(nil).ListNotes), ctx, params) +} + +// SoftDeleteNote mocks base method. +func (m *MockNoteStore) SoftDeleteNote(ctx context.Context, orgID, id int64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SoftDeleteNote", ctx, orgID, id) + ret0, _ := ret[0].(error) + return ret0 +} + +// SoftDeleteNote indicates an expected call of SoftDeleteNote. +func (mr *MockNoteStoreMockRecorder) SoftDeleteNote(ctx, orgID, id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SoftDeleteNote", reflect.TypeOf((*MockNoteStore)(nil).SoftDeleteNote), ctx, orgID, id) +} + +// UpdateNoteContent mocks base method. +func (m *MockNoteStore) UpdateNoteContent(ctx context.Context, orgID, id, authorUserID int64, content string) (*models.Note, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateNoteContent", ctx, orgID, id, authorUserID, content) + ret0, _ := ret[0].(*models.Note) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateNoteContent indicates an expected call of UpdateNoteContent. +func (mr *MockNoteStoreMockRecorder) UpdateNoteContent(ctx, orgID, id, authorUserID, content any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateNoteContent", reflect.TypeOf((*MockNoteStore)(nil).UpdateNoteContent), ctx, orgID, id, authorUserID, content) +} diff --git a/server/internal/domain/stores/interfaces/note.go b/server/internal/domain/stores/interfaces/note.go new file mode 100644 index 0000000000..3262161cfa --- /dev/null +++ b/server/internal/domain/stores/interfaces/note.go @@ -0,0 +1,38 @@ +package interfaces + +import ( + "context" + + "github.com/block/proto-fleet/server/internal/domain/notes/models" +) + +//go:generate go run go.uber.org/mock/mockgen -source=note.go -destination=mocks/mock_note_store.go -package=mocks NoteStore + +// NoteStore is the persistence boundary for the shared team notepad. +// All methods are org-scoped; cross-org reads are not supported. +type NoteStore interface { + // CreateNote inserts a new note row and returns it. The returned + // Note has an empty AuthorUsername — the caller stamps it from the + // session. + CreateNote(ctx context.Context, orgID, userID int64, content string) (*models.Note, error) + + // GetNote returns the live note or NotFound. AuthorUsername is not + // populated. + GetNote(ctx context.Context, orgID, id int64) (*models.Note, error) + + // ListNotes returns one feed page, newest first, with the author's + // username joined in. + ListNotes(ctx context.Context, params models.ListNotesParams) ([]models.Note, error) + + // UpdateNoteContent updates the live note's content iff it belongs + // to authorUserID — the ownership predicate is in the SQL so it + // cannot race the caller's read. Returns NotFound when no row + // matches (missing, deleted, or different author). AuthorUsername + // is not populated on the returned Note. + UpdateNoteContent(ctx context.Context, orgID, id, authorUserID int64, content string) (*models.Note, error) + + // SoftDeleteNote sets deleted_at on the live note. Returns NotFound + // when the note is missing or already deleted. Authorization + // (author-or-moderator) is the domain layer's responsibility. + SoftDeleteNote(ctx context.Context, orgID, id int64) error +} diff --git a/server/internal/domain/stores/sqlstores/note.go b/server/internal/domain/stores/sqlstores/note.go new file mode 100644 index 0000000000..36e5e0cc7d --- /dev/null +++ b/server/internal/domain/stores/sqlstores/note.go @@ -0,0 +1,118 @@ +package sqlstores + +import ( + "context" + "database/sql" + "errors" + + "github.com/block/proto-fleet/server/generated/sqlc" + "github.com/block/proto-fleet/server/internal/domain/fleeterror" + "github.com/block/proto-fleet/server/internal/domain/notes/models" + "github.com/block/proto-fleet/server/internal/domain/stores/interfaces" +) + +var _ interfaces.NoteStore = &SQLNoteStore{} + +type SQLNoteStore struct { + SQLConnectionManager +} + +func NewSQLNoteStore(conn *sql.DB) *SQLNoteStore { + return &SQLNoteStore{SQLConnectionManager: NewSQLConnectionManager(conn)} +} + +func (s *SQLNoteStore) CreateNote(ctx context.Context, orgID, userID int64, content string) (*models.Note, error) { + row, err := s.GetQueries(ctx).CreateNote(ctx, sqlc.CreateNoteParams{ + OrgID: orgID, + UserID: userID, + Content: content, + }) + if err != nil { + return nil, fleeterror.NewInternalErrorf("failed to create note: %v", err) + } + out := noteFromRow(row) + return &out, nil +} + +func (s *SQLNoteStore) GetNote(ctx context.Context, orgID, id int64) (*models.Note, error) { + row, err := s.GetQueries(ctx).GetNote(ctx, sqlc.GetNoteParams{ID: id, OrgID: orgID}) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fleeterror.NewNotFoundErrorf("note %d not found", id) + } + return nil, fleeterror.NewInternalErrorf("failed to get note: %v", err) + } + out := noteFromRow(row) + return &out, nil +} + +func (s *SQLNoteStore) ListNotes(ctx context.Context, params models.ListNotesParams) ([]models.Note, error) { + arg := sqlc.ListNotesParams{ + OrgID: params.OrgID, + PageSize: params.PageSize, + } + if params.CursorTime != nil { + arg.CursorTime = sql.NullTime{Time: *params.CursorTime, Valid: true} + } + if params.CursorID != nil { + arg.CursorID = sql.NullInt64{Int64: *params.CursorID, Valid: true} + } + rows, err := s.GetQueries(ctx).ListNotes(ctx, arg) + if err != nil { + return nil, fleeterror.NewInternalErrorf("failed to list notes: %v", err) + } + out := make([]models.Note, 0, len(rows)) + for _, row := range rows { + out = append(out, models.Note{ + ID: row.ID, + OrgID: row.OrgID, + UserID: row.UserID, + AuthorUsername: row.AuthorUsername, + Content: row.Content, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + }) + } + return out, nil +} + +func (s *SQLNoteStore) UpdateNoteContent(ctx context.Context, orgID, id, authorUserID int64, content string) (*models.Note, error) { + row, err := s.GetQueries(ctx).UpdateNoteContent(ctx, sqlc.UpdateNoteContentParams{ + Content: content, + ID: id, + OrgID: orgID, + UserID: authorUserID, + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fleeterror.NewNotFoundErrorf("note %d not found", id) + } + return nil, fleeterror.NewInternalErrorf("failed to update note: %v", err) + } + out := noteFromRow(row) + return &out, nil +} + +func (s *SQLNoteStore) SoftDeleteNote(ctx context.Context, orgID, id int64) error { + affected, err := s.GetQueries(ctx).SoftDeleteNote(ctx, sqlc.SoftDeleteNoteParams{ID: id, OrgID: orgID}) + if err != nil { + return fleeterror.NewInternalErrorf("failed to delete note: %v", err) + } + if affected == 0 { + return fleeterror.NewNotFoundErrorf("note %d not found", id) + } + return nil +} + +// noteFromRow maps the bare note row (no username join) used by the +// create/get/update paths. +func noteFromRow(row sqlc.Note) models.Note { + return models.Note{ + ID: row.ID, + OrgID: row.OrgID, + UserID: row.UserID, + Content: row.Content, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} diff --git a/server/internal/domain/stores/sqlstores/note_integration_test.go b/server/internal/domain/stores/sqlstores/note_integration_test.go new file mode 100644 index 0000000000..6c43e670ec --- /dev/null +++ b/server/internal/domain/stores/sqlstores/note_integration_test.go @@ -0,0 +1,152 @@ +package sqlstores_test + +import ( + "errors" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + + "github.com/block/proto-fleet/server/internal/domain/fleeterror" + "github.com/block/proto-fleet/server/internal/domain/notes/models" + "github.com/block/proto-fleet/server/internal/domain/stores/sqlstores" + "github.com/block/proto-fleet/server/internal/testutil" +) + +func requireNotFound(t *testing.T, err error) { + t.Helper() + require.Error(t, err) + var fe fleeterror.FleetError + require.True(t, errors.As(err, &fe), "expected FleetError, got %T", err) + require.Equal(t, connect.CodeNotFound, fe.GRPCCode) +} + +// TestNoteStore_FeedPagination walks the keyset cursor across three +// pages that include rows sharing one created_at value, proving the id +// tiebreak yields no duplicates and no skips. CURRENT_TIMESTAMP would +// give every insert a distinct time, so the test pins created_at by +// raw UPDATE after insert. +func TestNoteStore_FeedPagination(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database integration test in short mode") + } + + testContext := testutil.InitializeDBServiceInfrastructure(t) + alice := testContext.DatabaseService.CreateSuperAdminUser() + store := sqlstores.NewSQLNoteStore(testContext.ServiceProvider.DB) + + older := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC) + newer := older.Add(time.Hour) + pin := func(noteID int64, at time.Time) { + _, err := testContext.ServiceProvider.DB.ExecContext(t.Context(), + `UPDATE note SET created_at = $1 WHERE id = $2`, at, noteID) + require.NoError(t, err) + } + + var ids []int64 + for range 5 { + note, err := store.CreateNote(t.Context(), alice.OrganizationID, alice.DatabaseID, "note") + require.NoError(t, err) + ids = append(ids, note.ID) + } + // ids[0..1] share the older timestamp; ids[2..4] share the newer + // one, so both page boundaries cross an id tiebreak. + pin(ids[0], older) + pin(ids[1], older) + pin(ids[2], newer) + pin(ids[3], newer) + pin(ids[4], newer) + + wantOrder := []int64{ids[4], ids[3], ids[2], ids[1], ids[0]} + + var walked []int64 + params := models.ListNotesParams{OrgID: alice.OrganizationID, PageSize: 2} + for range 4 { + rows, err := store.ListNotes(t.Context(), params) + require.NoError(t, err) + for _, row := range rows { + walked = append(walked, row.ID) + require.Equal(t, "alice@example.com", row.AuthorUsername, + "the user join supplies the display username") + } + if len(rows) < 2 { + break + } + last := rows[len(rows)-1] + cursorTime := last.CreatedAt + cursorID := last.ID + params.CursorTime = &cursorTime + params.CursorID = &cursorID + } + + require.Equal(t, wantOrder, walked, + "cursor walk must visit every live note exactly once, newest first, id-descending within equal timestamps") +} + +func TestNoteStore_CrossOrgIsolationAndOwnership(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database integration test in short mode") + } + + testContext := testutil.InitializeDBServiceInfrastructure(t) + alice := testContext.DatabaseService.CreateSuperAdminUser() + bob := testContext.DatabaseService.CreateSuperAdminUser2() + store := sqlstores.NewSQLNoteStore(testContext.ServiceProvider.DB) + + bobNote, err := store.CreateNote(t.Context(), bob.OrganizationID, bob.DatabaseID, "org B secret") + require.NoError(t, err) + + // Org A sees an empty feed and cannot reach org B's note by id + // through any verb. + rows, err := store.ListNotes(t.Context(), models.ListNotesParams{OrgID: alice.OrganizationID, PageSize: 10}) + require.NoError(t, err) + require.Empty(t, rows, "org A's feed must not contain org B's notes") + + _, err = store.GetNote(t.Context(), alice.OrganizationID, bobNote.ID) + requireNotFound(t, err) + _, err = store.UpdateNoteContent(t.Context(), alice.OrganizationID, bobNote.ID, alice.DatabaseID, "hijack") + requireNotFound(t, err) + requireNotFound(t, store.SoftDeleteNote(t.Context(), alice.OrganizationID, bobNote.ID)) + + // Ownership predicate: a different user id in the author's own org + // cannot update the row. + aliceNote, err := store.CreateNote(t.Context(), alice.OrganizationID, alice.DatabaseID, "mine") + require.NoError(t, err) + _, err = store.UpdateNoteContent(t.Context(), alice.OrganizationID, aliceNote.ID, alice.DatabaseID+9999, "forged") + requireNotFound(t, err) + + // The legitimate author updates fine and the trigger bumps + // updated_at past created_at (the wire-level "edited" signal). + updated, err := store.UpdateNoteContent(t.Context(), alice.OrganizationID, aliceNote.ID, alice.DatabaseID, "mine, edited") + require.NoError(t, err) + require.Equal(t, "mine, edited", updated.Content) + require.True(t, updated.UpdatedAt.After(updated.CreatedAt), + "updated_at trigger must advance past created_at on edit") +} + +func TestNoteStore_SoftDeleteExcludesFromReads(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database integration test in short mode") + } + + testContext := testutil.InitializeDBServiceInfrastructure(t) + alice := testContext.DatabaseService.CreateSuperAdminUser() + store := sqlstores.NewSQLNoteStore(testContext.ServiceProvider.DB) + + note, err := store.CreateNote(t.Context(), alice.OrganizationID, alice.DatabaseID, "ephemeral") + require.NoError(t, err) + + require.NoError(t, store.SoftDeleteNote(t.Context(), alice.OrganizationID, note.ID)) + + _, err = store.GetNote(t.Context(), alice.OrganizationID, note.ID) + requireNotFound(t, err) + + rows, err := store.ListNotes(t.Context(), models.ListNotesParams{OrgID: alice.OrganizationID, PageSize: 10}) + require.NoError(t, err) + require.Empty(t, rows) + + // Deleting an already-deleted note reports NotFound rather than + // silently succeeding. + requireNotFound(t, store.SoftDeleteNote(t.Context(), alice.OrganizationID, note.ID)) +} diff --git a/server/internal/handlers/handlerstest/permissions.go b/server/internal/handlers/handlerstest/permissions.go index 24c17a3907..e9b0347a5d 100644 --- a/server/internal/handlers/handlerstest/permissions.go +++ b/server/internal/handlers/handlerstest/permissions.go @@ -33,3 +33,30 @@ func CtxWithPermissions(t *testing.T, orgID int64, permissions ...string) contex }}) return middleware.WithEffectivePermissions(ctx, eff) } + +// CtxWithSiteScopedPermissions is CtxWithPermissions with the grant +// attached to a single site-scope assignment instead of an org-scope +// one. Use it to exercise the *Anywhere gates from the perspective of +// a caller (e.g. a site-scoped FIELD_TECH) who holds no org-scope +// assignment at all. +func CtxWithSiteScopedPermissions(t *testing.T, orgID, siteID int64, permissions ...string) context.Context { + t.Helper() + ctx := authn.SetInfo(t.Context(), &session.Info{OrganizationID: orgID}) + eff := authz.NewEffectivePermissions([]authz.Assignment{{ + AssignmentID: 1, + ScopeType: authz.ScopeSite, + SiteID: &siteID, + Permissions: permissions, + }}) + return middleware.WithEffectivePermissions(ctx, eff) +} + +// CtxWithSessionInfo carries a caller-supplied session.Info (tests +// that assert on identity fields like UserID/Username populate them +// here) plus the given assignments. Handlers that stamp authorship +// from the session need this richer variant. +func CtxWithSessionInfo(t *testing.T, info *session.Info, assignments ...authz.Assignment) context.Context { + t.Helper() + ctx := authn.SetInfo(t.Context(), info) + return middleware.WithEffectivePermissions(ctx, authz.NewEffectivePermissions(assignments)) +} diff --git a/server/internal/handlers/middleware/permission.go b/server/internal/handlers/middleware/permission.go index 03554e2a0d..a5b08d53ce 100644 --- a/server/internal/handlers/middleware/permission.go +++ b/server/internal/handlers/middleware/permission.go @@ -3,6 +3,7 @@ package middleware import ( "context" "encoding/json" + "slices" "github.com/block/proto-fleet/server/internal/domain/authz" "github.com/block/proto-fleet/server/internal/domain/fleeterror" @@ -177,6 +178,125 @@ func RequireAnyPermission(ctx context.Context, keys []string, rc authz.ResourceC return nil, permissionDeniedError(keys[0], rc) } +// RequirePermissionAnywhere gates a handler on the caller holding the +// named permission key under ANY of their assignments — org scope or +// any site scope. Use it only for org-shared collaborative resources +// (the team notepad) that have no site dimension to narrow on: a +// site-scoped FIELD_TECH is as much a member of the team such a +// resource serves as an org-scoped ADMIN, so membership — not scope — +// is the gate. For anything tied to a site, use RequirePermission with +// a ResourceContext so narrowing applies. +// +// Authentication, internal-actor allowlisting, fail-closed handling, +// and the structured denial payload match RequirePermission exactly; +// the payload's scope object is empty because there is no resource +// scope to echo. +func RequirePermissionAnywhere(ctx context.Context, key string) (*session.Info, error) { + info, err := session.GetInfo(ctx) + if err != nil { + return nil, fleeterror.NewUnauthenticatedError("authentication required") + } + + // Same allowlisted internal-actor short-circuit as RequirePermission. + if info.Actor != "" { + switch info.Actor { + case session.ActorScheduler, session.ActorCurtailment: + return info, nil + default: + return nil, fleeterror.NewInternalErrorf( + "authz: unknown internal actor %q; refusing to short-circuit RBAC", + info.Actor, + ) + } + } + + eff := effectivePermissionsFromContext(ctx) + if eff == nil { + return nil, fleeterror.NewInternalError( + "authz: effective permissions missing from request context; auth interceptor wiring is broken", + ) + } + + if !eff.HasAnywhere(key) { + return nil, permissionDeniedError(key, authz.ResourceContext{}) + } + return info, nil +} + +// RequireAnyPermissionAnywhere is RequireAnyPermission with +// HasAnywhere semantics: the caller must hold at least one of the keys +// under any of their assignments. The first key is the "primary" gate +// for error messaging, matching RequireAnyPermission's contract. +func RequireAnyPermissionAnywhere(ctx context.Context, keys []string) (*session.Info, error) { + if len(keys) == 0 { + // Programming error — surface as Internal so a misuse fails + // closed rather than silently allowing. + return nil, fleeterror.NewInternalError( + "authz: RequireAnyPermissionAnywhere called with no keys; refusing to short-circuit RBAC", + ) + } + + info, err := session.GetInfo(ctx) + if err != nil { + return nil, fleeterror.NewUnauthenticatedError("authentication required") + } + + // Same allowlisted internal-actor short-circuit as RequirePermission. + if info.Actor != "" { + switch info.Actor { + case session.ActorScheduler, session.ActorCurtailment: + return info, nil + default: + return nil, fleeterror.NewInternalErrorf( + "authz: unknown internal actor %q; refusing to short-circuit RBAC", + info.Actor, + ) + } + } + + eff := effectivePermissionsFromContext(ctx) + if eff == nil { + return nil, fleeterror.NewInternalError( + "authz: effective permissions missing from request context; auth interceptor wiring is broken", + ) + } + + if slices.ContainsFunc(keys, eff.HasAnywhere) { + return info, nil + } + return nil, permissionDeniedError(keys[0], authz.ResourceContext{}) +} + +// CallerHasPermissionAnywhere reports whether the caller holds the key +// under any of their assignments, without erroring. It is a capability +// probe for handlers that branch on an extra grant after their primary +// gate has already passed (e.g. DeleteNote consults note:manage to +// decide whether the caller may moderate other authors' notes). +// Allowlisted internal actors probe true; an unknown actor or a +// missing EffectivePermissions probes false — fail closed, consistent +// with the Require* gates. +func CallerHasPermissionAnywhere(ctx context.Context, key string) bool { + info, err := session.GetInfo(ctx) + if err != nil { + return false + } + + if info.Actor != "" { + switch info.Actor { + case session.ActorScheduler, session.ActorCurtailment: + return true + default: + return false + } + } + + eff := effectivePermissionsFromContext(ctx) + if eff == nil { + return false + } + return eff.HasAnywhere(key) +} + // permissionDeniedError builds a Connect PermissionDenied error whose // body is the structured payload the plan specifies: // diff --git a/server/internal/handlers/middleware/permission_test.go b/server/internal/handlers/middleware/permission_test.go index 4a51415943..75935bfb35 100644 --- a/server/internal/handlers/middleware/permission_test.go +++ b/server/internal/handlers/middleware/permission_test.go @@ -309,3 +309,146 @@ func connectMessage(t *testing.T, err error) string { require.True(t, errors.As(err, &fe), "expected FleetError, got %T", err) return fe.DebugMessage } + +func TestRequirePermissionAnywhere_AllowsOrgScope(t *testing.T) { + ctx := ctxWithEffective(t, userInfo(), orgAssignment(authz.PermNoteRead)) + + info, err := middleware.RequirePermissionAnywhere(ctx, authz.PermNoteRead) + require.NoError(t, err) + require.Equal(t, "alice", info.Username) +} + +func TestRequirePermissionAnywhere_AllowsSiteScopeOnly(t *testing.T) { + // The gate's reason to exist: a caller whose only assignment is + // site-scoped is still a member of the org-shared surface. + ctx := ctxWithEffective(t, userInfo(), siteAssignment(7, authz.PermNoteRead)) + + info, err := middleware.RequirePermissionAnywhere(ctx, authz.PermNoteRead) + require.NoError(t, err) + require.NotNil(t, info) +} + +func TestRequirePermissionAnywhere_DeniesWithStructuredPayload(t *testing.T) { + ctx := ctxWithEffective(t, userInfo(), orgAssignment(authz.PermFleetRead)) + + info, err := middleware.RequirePermissionAnywhere(ctx, authz.PermNoteRead) + require.Error(t, err) + require.Nil(t, info) + require.Equal(t, connect.CodePermissionDenied, connectCode(t, err)) + + // Same payload contract as RequirePermission; scope is the empty + // object because the resource has no site dimension. + var payload struct { + Required string `json:"required"` + Scope map[string]any `json:"scope"` + } + require.NoError(t, json.Unmarshal([]byte(connectMessage(t, err)), &payload)) + require.Equal(t, authz.PermNoteRead, payload.Required) + require.Empty(t, payload.Scope) +} + +func TestRequirePermissionAnywhere_UnauthenticatedWhenNoSessionInfo(t *testing.T) { + _, err := middleware.RequirePermissionAnywhere(context.Background(), authz.PermNoteRead) + require.Error(t, err) + require.Equal(t, connect.CodeUnauthenticated, connectCode(t, err)) +} + +func TestRequirePermissionAnywhere_FailClosedOnMissingEffective(t *testing.T) { + ctx := ctxWithInfo(userInfo()) // no WithEffectivePermissions + + _, err := middleware.RequirePermissionAnywhere(ctx, authz.PermNoteRead) + require.Error(t, err) + require.Equal(t, connect.CodeInternal, connectCode(t, err)) +} + +func TestRequirePermissionAnywhere_SchedulerShortCircuitsToAllow(t *testing.T) { + info := &session.Info{ + AuthMethod: session.AuthMethodSession, + Actor: session.ActorScheduler, + OrganizationID: 1, + } + ctx := ctxWithInfo(info) // deliberately no WithEffectivePermissions + + got, err := middleware.RequirePermissionAnywhere(ctx, authz.PermNoteCreate) + require.NoError(t, err) + require.Equal(t, session.ActorScheduler, got.Actor) +} + +func TestRequirePermissionAnywhere_UnknownActorDoesNotBypass(t *testing.T) { + info := &session.Info{ + AuthMethod: session.AuthMethodSession, + Actor: session.Actor("future-orchestrator-typo"), + } + ctx := ctxWithInfo(info) + + _, err := middleware.RequirePermissionAnywhere(ctx, authz.PermNoteRead) + require.Error(t, err) + require.Equal(t, connect.CodeInternal, connectCode(t, err)) +} + +func TestRequireAnyPermissionAnywhere_AllowsWhenSecondKeyMatches(t *testing.T) { + // Moderator-only role: holds note:manage but not note:create. + ctx := ctxWithEffective(t, userInfo(), siteAssignment(3, authz.PermNoteManage)) + + info, err := middleware.RequireAnyPermissionAnywhere(ctx, []string{authz.PermNoteCreate, authz.PermNoteManage}) + require.NoError(t, err) + require.NotNil(t, info) +} + +func TestRequireAnyPermissionAnywhere_DeniesWhenNoKeyMatches(t *testing.T) { + ctx := ctxWithEffective(t, userInfo(), orgAssignment(authz.PermFleetRead)) + + _, err := middleware.RequireAnyPermissionAnywhere(ctx, []string{authz.PermNoteCreate, authz.PermNoteManage}) + require.Error(t, err) + require.Equal(t, connect.CodePermissionDenied, connectCode(t, err)) + + // Denial reports the first (primary) key, matching + // RequireAnyPermission's contract. + var payload struct { + Required string `json:"required"` + } + require.NoError(t, json.Unmarshal([]byte(connectMessage(t, err)), &payload)) + require.Equal(t, authz.PermNoteCreate, payload.Required) +} + +func TestRequireAnyPermissionAnywhere_FailClosedOnEmptyKeys(t *testing.T) { + ctx := ctxWithEffective(t, userInfo(), orgAssignment(authz.PermNoteRead)) + + _, err := middleware.RequireAnyPermissionAnywhere(ctx, nil) + require.Error(t, err) + require.Equal(t, connect.CodeInternal, connectCode(t, err)) +} + +func TestCallerHasPermissionAnywhere_TrueForAnyScope(t *testing.T) { + require.True(t, middleware.CallerHasPermissionAnywhere( + ctxWithEffective(t, userInfo(), orgAssignment(authz.PermNoteManage)), authz.PermNoteManage)) + require.True(t, middleware.CallerHasPermissionAnywhere( + ctxWithEffective(t, userInfo(), siteAssignment(5, authz.PermNoteManage)), authz.PermNoteManage)) +} + +func TestCallerHasPermissionAnywhere_FalseWhenAbsentOrUnwired(t *testing.T) { + require.False(t, middleware.CallerHasPermissionAnywhere( + ctxWithEffective(t, userInfo(), orgAssignment(authz.PermNoteRead)), authz.PermNoteManage), + "missing key probes false") + require.False(t, middleware.CallerHasPermissionAnywhere( + ctxWithInfo(userInfo()), authz.PermNoteManage), + "missing EffectivePermissions probes false — fail closed") + require.False(t, middleware.CallerHasPermissionAnywhere( + context.Background(), authz.PermNoteManage), + "no session probes false") +} + +func TestCallerHasPermissionAnywhere_ActorAllowlist(t *testing.T) { + scheduler := ctxWithInfo(&session.Info{ + AuthMethod: session.AuthMethodSession, + Actor: session.ActorScheduler, + }) + require.True(t, middleware.CallerHasPermissionAnywhere(scheduler, authz.PermNoteManage)) + + unknown := ctxWithInfo(&session.Info{ + AuthMethod: session.AuthMethodSession, + Actor: session.Actor("future-orchestrator-typo"), + }) + require.False(t, middleware.CallerHasPermissionAnywhere(unknown, authz.PermNoteManage), + "unknown actor probes false — fail closed") +} diff --git a/server/internal/handlers/middleware/rpc_permissions.go b/server/internal/handlers/middleware/rpc_permissions.go index a8b4a79f45..627fa7f127 100644 --- a/server/internal/handlers/middleware/rpc_permissions.go +++ b/server/internal/handlers/middleware/rpc_permissions.go @@ -15,6 +15,7 @@ import ( "github.com/block/proto-fleet/server/generated/grpc/foremanimport/v1/foremanimportv1connect" "github.com/block/proto-fleet/server/generated/grpc/minercommand/v1/minercommandv1connect" "github.com/block/proto-fleet/server/generated/grpc/networkinfo/v1/networkinfov1connect" + "github.com/block/proto-fleet/server/generated/grpc/notes/v1/notesv1connect" "github.com/block/proto-fleet/server/generated/grpc/onboarding/v1/onboardingv1connect" "github.com/block/proto-fleet/server/generated/grpc/pairing/v1/pairingv1connect" "github.com/block/proto-fleet/server/generated/grpc/pools/v1/poolsv1connect" @@ -234,6 +235,18 @@ var ProcedurePermissions = map[string]string{ networkinfov1connect.NetworkInfoServiceGetNetworkInfoProcedure: authz.PermFleetRead, networkinfov1connect.NetworkInfoServiceUpdateNetworkNicknameProcedure: authz.PermSiteManage, + // NoteService — the shared team notepad, an org-shared resource with + // no site dimension. All four RPCs gate through the *Anywhere + // middleware variants, so a grant at any scope (org or site) + // passes. DeleteNote's handler calls RequireAnyPermissionAnywhere + // with [note:create, note:manage] — the map records the primary + // gate; the moderation alternate and the author-only rule are + // enforced in the handler/domain. + notesv1connect.NoteServiceListNotesProcedure: authz.PermNoteRead, + notesv1connect.NoteServiceCreateNoteProcedure: authz.PermNoteCreate, + notesv1connect.NoteServiceUpdateNoteProcedure: authz.PermNoteCreate, + notesv1connect.NoteServiceDeleteNoteProcedure: authz.PermNoteCreate, + // OnboardingService — fleet-init status. Other onboarding procedures // are unauthenticated (covered by UnauthenticatedProcedures). onboardingv1connect.OnboardingServiceGetFleetOnboardingStatusProcedure: authz.PermFleetRead, diff --git a/server/internal/handlers/middleware/rpc_permissions_test.go b/server/internal/handlers/middleware/rpc_permissions_test.go index 22bd7865b4..bcfe99249a 100644 --- a/server/internal/handlers/middleware/rpc_permissions_test.go +++ b/server/internal/handlers/middleware/rpc_permissions_test.go @@ -28,6 +28,7 @@ import ( "github.com/block/proto-fleet/server/generated/grpc/foremanimport/v1/foremanimportv1connect" "github.com/block/proto-fleet/server/generated/grpc/minercommand/v1/minercommandv1connect" "github.com/block/proto-fleet/server/generated/grpc/networkinfo/v1/networkinfov1connect" + "github.com/block/proto-fleet/server/generated/grpc/notes/v1/notesv1connect" "github.com/block/proto-fleet/server/generated/grpc/onboarding/v1/onboardingv1connect" "github.com/block/proto-fleet/server/generated/grpc/pairing/v1/pairingv1connect" "github.com/block/proto-fleet/server/generated/grpc/pools/v1/poolsv1connect" @@ -77,6 +78,7 @@ var registeredServices = []struct { {foremanimportv1connect.ForemanImportServiceName, reflect.TypeOf((*foremanimportv1connect.ForemanImportServiceHandler)(nil)).Elem()}, {minercommandv1connect.MinerCommandServiceName, reflect.TypeOf((*minercommandv1connect.MinerCommandServiceHandler)(nil)).Elem()}, {networkinfov1connect.NetworkInfoServiceName, reflect.TypeOf((*networkinfov1connect.NetworkInfoServiceHandler)(nil)).Elem()}, + {notesv1connect.NoteServiceName, reflect.TypeOf((*notesv1connect.NoteServiceHandler)(nil)).Elem()}, {onboardingv1connect.OnboardingServiceName, reflect.TypeOf((*onboardingv1connect.OnboardingServiceHandler)(nil)).Elem()}, {pairingv1connect.PairingServiceName, reflect.TypeOf((*pairingv1connect.PairingServiceHandler)(nil)).Elem()}, {poolsv1connect.PoolsServiceName, reflect.TypeOf((*poolsv1connect.PoolsServiceHandler)(nil)).Elem()}, diff --git a/server/internal/handlers/notes/handler.go b/server/internal/handlers/notes/handler.go new file mode 100644 index 0000000000..ac1b8e2c04 --- /dev/null +++ b/server/internal/handlers/notes/handler.go @@ -0,0 +1,151 @@ +// Package notes is the Connect-RPC surface for NoteService, the +// org-wide shared team notepad. Translation between proto and domain +// types lives in translate.go; this file is the wiring + auth gates. +// +// All gates use the *Anywhere middleware variants: the notepad is an +// org-shared collaborative resource with no site dimension, so a role +// assignment at any scope (org or site) makes the caller a member of +// the team the feed serves. +package notes + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "time" + + "connectrpc.com/connect" + + pb "github.com/block/proto-fleet/server/generated/grpc/notes/v1" + "github.com/block/proto-fleet/server/generated/grpc/notes/v1/notesv1connect" + "github.com/block/proto-fleet/server/internal/domain/authz" + "github.com/block/proto-fleet/server/internal/domain/fleeterror" + "github.com/block/proto-fleet/server/internal/domain/notes" + "github.com/block/proto-fleet/server/internal/domain/notes/models" + "github.com/block/proto-fleet/server/internal/handlers/middleware" +) + +// Handler implements the NoteService Connect-RPC surface. +type Handler struct { + service *notes.Service +} + +var _ notesv1connect.NoteServiceHandler = &Handler{} + +// NewHandler returns a NoteService handler bound to the supplied +// domain service. +func NewHandler(service *notes.Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) ListNotes(ctx context.Context, req *connect.Request[pb.ListNotesRequest]) (*connect.Response[pb.ListNotesResponse], error) { + info, err := middleware.RequirePermissionAnywhere(ctx, authz.PermNoteRead) + if err != nil { + return nil, err + } + + params := models.ListNotesParams{ + OrgID: info.OrganizationID, + PageSize: req.Msg.GetPageSize(), + } + if token := req.Msg.GetPageToken(); token != "" { + createdAt, id, err := decodeCursor(token) + if err != nil { + return nil, fleeterror.NewInvalidArgumentError(err.Error()) + } + params.CursorTime = &createdAt + params.CursorID = &id + } + + rows, err := h.service.ListNotes(ctx, params) + if err != nil { + return nil, err + } + + var nextPageToken string + if len(rows) == int(req.Msg.GetPageSize()) && len(rows) > 0 { + last := rows[len(rows)-1] + nextPageToken, err = encodeCursor(last.CreatedAt, last.ID) + if err != nil { + return nil, fleeterror.NewInternalError(err.Error()) + } + } + + return connect.NewResponse(toListNotesResponse(rows, nextPageToken)), nil +} + +func (h *Handler) CreateNote(ctx context.Context, req *connect.Request[pb.CreateNoteRequest]) (*connect.Response[pb.CreateNoteResponse], error) { + info, err := middleware.RequirePermissionAnywhere(ctx, authz.PermNoteCreate) + if err != nil { + return nil, err + } + + note, err := h.service.CreateNote(ctx, info.OrganizationID, info.UserID, info.Username, req.Msg.GetContent()) + if err != nil { + return nil, err + } + return connect.NewResponse(&pb.CreateNoteResponse{Note: toProtoNote(note)}), nil +} + +func (h *Handler) UpdateNote(ctx context.Context, req *connect.Request[pb.UpdateNoteRequest]) (*connect.Response[pb.UpdateNoteResponse], error) { + info, err := middleware.RequirePermissionAnywhere(ctx, authz.PermNoteCreate) + if err != nil { + return nil, err + } + + note, err := h.service.UpdateNote(ctx, info.OrganizationID, req.Msg.GetId(), info.UserID, info.Username, req.Msg.GetContent()) + if err != nil { + return nil, err + } + return connect.NewResponse(&pb.UpdateNoteResponse{Note: toProtoNote(note)}), nil +} + +func (h *Handler) DeleteNote(ctx context.Context, req *connect.Request[pb.DeleteNoteRequest]) (*connect.Response[pb.DeleteNoteResponse], error) { + // note:create lets an author delete their own note; note:manage + // alone also passes so a moderator-only role works. The domain + // layer enforces the author-or-moderator rule. + info, err := middleware.RequireAnyPermissionAnywhere(ctx, []string{authz.PermNoteCreate, authz.PermNoteManage}) + if err != nil { + return nil, err + } + canModerate := middleware.CallerHasPermissionAnywhere(ctx, authz.PermNoteManage) + + if err := h.service.DeleteNote(ctx, info.OrganizationID, req.Msg.GetId(), info.UserID, canModerate); err != nil { + return nil, err + } + return connect.NewResponse(&pb.DeleteNoteResponse{}), nil +} + +// --- cursor encoding --- + +type pageCursor struct { + CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` +} + +func encodeCursor(createdAt time.Time, id int64) (string, error) { + data, err := json.Marshal(pageCursor{CreatedAt: createdAt, ID: id}) + if err != nil { + return "", fmt.Errorf("encoding page cursor: %w", err) + } + return base64.URLEncoding.EncodeToString(data), nil +} + +func decodeCursor(token string) (time.Time, int64, error) { + data, err := base64.URLEncoding.DecodeString(token) + if err != nil { + return time.Time{}, 0, fmt.Errorf("invalid page token encoding: %w", err) + } + var c pageCursor + if err := json.Unmarshal(data, &c); err != nil { + return time.Time{}, 0, fmt.Errorf("invalid page token format: %w", err) + } + if c.CreatedAt.IsZero() { + return time.Time{}, 0, fmt.Errorf("invalid page token: missing created_at") + } + if c.ID <= 0 { + return time.Time{}, 0, fmt.Errorf("invalid page token: missing id") + } + return c.CreatedAt, c.ID, nil +} diff --git a/server/internal/handlers/notes/handler_test.go b/server/internal/handlers/notes/handler_test.go new file mode 100644 index 0000000000..8ef21f71ec --- /dev/null +++ b/server/internal/handlers/notes/handler_test.go @@ -0,0 +1,320 @@ +package notes + +import ( + "context" + "encoding/base64" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + pb "github.com/block/proto-fleet/server/generated/grpc/notes/v1" + "github.com/block/proto-fleet/server/internal/domain/authz" + "github.com/block/proto-fleet/server/internal/domain/fleeterror" + "github.com/block/proto-fleet/server/internal/domain/notes" + "github.com/block/proto-fleet/server/internal/domain/notes/models" + "github.com/block/proto-fleet/server/internal/domain/session" + "github.com/block/proto-fleet/server/internal/domain/stores/interfaces/mocks" + "github.com/block/proto-fleet/server/internal/handlers/handlerstest" +) + +// testHarness wires a real *notes.Service against a mock store so +// handler tests exercise both the auth gate and the body. activitySvc +// is nil; Log is nil-safe so audit fire-and-forget no-ops in tests. +type testHarness struct { + handler *Handler + store *mocks.MockNoteStore +} + +func newTestHandler(t *testing.T) *testHarness { + t.Helper() + ctrl := gomock.NewController(t) + store := mocks.NewMockNoteStore(ctrl) + return &testHarness{ + handler: NewHandler(notes.NewService(store, nil)), + store: store, + } +} + +// authorCtx builds a caller with note:read + note:create at org scope +// and the identity fields the handler stamps authorship from. +func authorCtx(t *testing.T, orgID, userID int64, username string, extraPerms ...string) context.Context { + t.Helper() + perms := append([]string{authz.PermNoteRead, authz.PermNoteCreate}, extraPerms...) + return handlerstest.CtxWithSessionInfo(t, + &session.Info{OrganizationID: orgID, UserID: userID, Username: username}, + authz.Assignment{AssignmentID: 1, ScopeType: authz.ScopeOrg, Permissions: perms}, + ) +} + +// siteScopedAuthorCtx is authorCtx with the grant attached to a single +// site-scope assignment — the FIELD_TECH@Site-A shape the *Anywhere +// gates exist for. +func siteScopedAuthorCtx(t *testing.T, orgID, siteID, userID int64, username string, perms ...string) context.Context { + t.Helper() + return handlerstest.CtxWithSessionInfo(t, + &session.Info{OrganizationID: orgID, UserID: userID, Username: username}, + authz.Assignment{AssignmentID: 1, ScopeType: authz.ScopeSite, SiteID: &siteID, Permissions: perms}, + ) +} + +func requireCode(t *testing.T, err error, want connect.Code) { + t.Helper() + require.Error(t, err) + var fe fleeterror.FleetError + require.ErrorAs(t, err, &fe) + require.Equal(t, want, fe.GRPCCode) +} + +func TestHandler_authGate(t *testing.T) { + t.Parallel() + + h := NewHandler(nil) // gate rejects before the body can touch the nil service + + cases := []struct { + name string + call func(ctx context.Context) error + }{ + {"ListNotes", func(ctx context.Context) error { + _, err := h.ListNotes(ctx, connect.NewRequest(&pb.ListNotesRequest{PageSize: 10})) + return err + }}, + {"CreateNote", func(ctx context.Context) error { + _, err := h.CreateNote(ctx, connect.NewRequest(&pb.CreateNoteRequest{Content: "hi"})) + return err + }}, + {"UpdateNote", func(ctx context.Context) error { + _, err := h.UpdateNote(ctx, connect.NewRequest(&pb.UpdateNoteRequest{Id: 1, Content: "hi"})) + return err + }}, + {"DeleteNote", func(ctx context.Context) error { + _, err := h.DeleteNote(ctx, connect.NewRequest(&pb.DeleteNoteRequest{Id: 1})) + return err + }}, + } + + for _, tc := range cases { + t.Run(tc.name+" rejects caller without note permissions", func(t *testing.T) { + t.Parallel() + ctx := handlerstest.CtxWithPermissions(t, 1, authz.PermFleetRead) + requireCode(t, tc.call(ctx), connect.CodePermissionDenied) + }) + t.Run(tc.name+" rejects unauthenticated caller", func(t *testing.T) { + t.Parallel() + requireCode(t, tc.call(context.Background()), connect.CodeUnauthenticated) + }) + } +} + +func TestHandler_readOnlyRoleCannotPost(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + ctx := handlerstest.CtxWithPermissions(t, 1, authz.PermNoteRead) + + h.store.EXPECT().ListNotes(gomock.Any(), gomock.Any()).Return(nil, nil) + _, err := h.handler.ListNotes(ctx, connect.NewRequest(&pb.ListNotesRequest{PageSize: 10})) + require.NoError(t, err, "note:read alone must satisfy ListNotes") + + _, err = h.handler.CreateNote(ctx, connect.NewRequest(&pb.CreateNoteRequest{Content: "hi"})) + requireCode(t, err, connect.CodePermissionDenied) +} + +func TestHandler_siteScopedCallerPassesAnywhereGates(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + + // A caller whose ONLY assignment is site-scoped — the regression + // the *Anywhere gates exist for. Has() with an org resource would + // deny this caller; the notepad must not. + ctx := siteScopedAuthorCtx(t, 1, 42, 7, "tech", + authz.PermNoteRead, authz.PermNoteCreate) + + h.store.EXPECT().ListNotes(gomock.Any(), gomock.Any()).Return(nil, nil) + _, err := h.handler.ListNotes(ctx, connect.NewRequest(&pb.ListNotesRequest{PageSize: 10})) + require.NoError(t, err) + + h.store.EXPECT().CreateNote(gomock.Any(), int64(1), int64(7), "hello from the field"). + Return(&models.Note{ID: 5, OrgID: 1, UserID: 7, Content: "hello from the field"}, nil) + resp, err := h.handler.CreateNote(ctx, connect.NewRequest(&pb.CreateNoteRequest{Content: "hello from the field"})) + require.NoError(t, err) + require.Equal(t, "tech", resp.Msg.GetNote().GetAuthorUsername(), + "author username is stamped from the session") +} + +func TestHandler_createTrimsAndRejectsWhitespaceContent(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + ctx := authorCtx(t, 1, 7, "alice") + + _, err := h.handler.CreateNote(ctx, connect.NewRequest(&pb.CreateNoteRequest{Content: " \n\t "})) + requireCode(t, err, connect.CodeInvalidArgument) + + h.store.EXPECT().CreateNote(gomock.Any(), int64(1), int64(7), "trimmed"). + Return(&models.Note{ID: 1, OrgID: 1, UserID: 7, Content: "trimmed"}, nil) + resp, err := h.handler.CreateNote(ctx, connect.NewRequest(&pb.CreateNoteRequest{Content: " trimmed \n"})) + require.NoError(t, err) + require.Equal(t, "trimmed", resp.Msg.GetNote().GetContent()) +} + +func TestHandler_updateIsAuthorOnly(t *testing.T) { + t.Parallel() + + t.Run("author edits own note", func(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + ctx := authorCtx(t, 1, 7, "alice") + + h.store.EXPECT().GetNote(gomock.Any(), int64(1), int64(5)). + Return(&models.Note{ID: 5, OrgID: 1, UserID: 7, Content: "old"}, nil) + h.store.EXPECT().UpdateNoteContent(gomock.Any(), int64(1), int64(5), int64(7), "new"). + Return(&models.Note{ID: 5, OrgID: 1, UserID: 7, Content: "new"}, nil) + + resp, err := h.handler.UpdateNote(ctx, connect.NewRequest(&pb.UpdateNoteRequest{Id: 5, Content: "new"})) + require.NoError(t, err) + require.Equal(t, "new", resp.Msg.GetNote().GetContent()) + require.Equal(t, "alice", resp.Msg.GetNote().GetAuthorUsername()) + }) + + t.Run("non-author is rejected even with note:manage", func(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + // Moderator holds note:manage — moderation covers deletion + // only, never editing another author's words. + ctx := authorCtx(t, 1, 8, "mallory", authz.PermNoteManage) + + h.store.EXPECT().GetNote(gomock.Any(), int64(1), int64(5)). + Return(&models.Note{ID: 5, OrgID: 1, UserID: 7, Content: "old"}, nil) + + _, err := h.handler.UpdateNote(ctx, connect.NewRequest(&pb.UpdateNoteRequest{Id: 5, Content: "new"})) + requireCode(t, err, connect.CodePermissionDenied) + }) + + t.Run("missing note is NotFound", func(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + ctx := authorCtx(t, 1, 7, "alice") + + h.store.EXPECT().GetNote(gomock.Any(), int64(1), int64(99)). + Return(nil, fleeterror.NewNotFoundErrorf("note %d not found", 99)) + + _, err := h.handler.UpdateNote(ctx, connect.NewRequest(&pb.UpdateNoteRequest{Id: 99, Content: "new"})) + requireCode(t, err, connect.CodeNotFound) + }) +} + +func TestHandler_deleteAuthorOrModerator(t *testing.T) { + t.Parallel() + + someoneElsesNote := &models.Note{ID: 5, OrgID: 1, UserID: 7, Content: "x"} + + t.Run("author deletes own note", func(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + ctx := authorCtx(t, 1, 7, "alice") + + h.store.EXPECT().GetNote(gomock.Any(), int64(1), int64(5)).Return(someoneElsesNote, nil) + h.store.EXPECT().SoftDeleteNote(gomock.Any(), int64(1), int64(5)).Return(nil) + + _, err := h.handler.DeleteNote(ctx, connect.NewRequest(&pb.DeleteNoteRequest{Id: 5})) + require.NoError(t, err) + }) + + t.Run("non-author without note:manage is rejected", func(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + ctx := authorCtx(t, 1, 8, "bob") + + h.store.EXPECT().GetNote(gomock.Any(), int64(1), int64(5)).Return(someoneElsesNote, nil) + + _, err := h.handler.DeleteNote(ctx, connect.NewRequest(&pb.DeleteNoteRequest{Id: 5})) + requireCode(t, err, connect.CodePermissionDenied) + }) + + t.Run("site-scoped note:manage moderates any note", func(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + // Moderator-only role, site-scoped: no note:create at all, so + // the delete gate's second key and the capability probe both + // have to resolve through HasAnywhere. + ctx := siteScopedAuthorCtx(t, 1, 42, 8, "mod", + authz.PermNoteRead, authz.PermNoteManage) + + h.store.EXPECT().GetNote(gomock.Any(), int64(1), int64(5)).Return(someoneElsesNote, nil) + h.store.EXPECT().SoftDeleteNote(gomock.Any(), int64(1), int64(5)).Return(nil) + + _, err := h.handler.DeleteNote(ctx, connect.NewRequest(&pb.DeleteNoteRequest{Id: 5})) + require.NoError(t, err) + }) +} + +func TestHandler_listPagination(t *testing.T) { + t.Parallel() + + base := time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC) + mkNote := func(id int64, at time.Time) models.Note { + return models.Note{ID: id, OrgID: 1, UserID: 7, AuthorUsername: "alice", Content: "n", CreatedAt: at, UpdatedAt: at} + } + + t.Run("full page emits a round-trippable next token", func(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + ctx := authorCtx(t, 1, 7, "alice") + + page1 := []models.Note{mkNote(3, base.Add(2*time.Minute)), mkNote(2, base.Add(time.Minute))} + h.store.EXPECT().ListNotes(gomock.Any(), models.ListNotesParams{OrgID: 1, PageSize: 2}).Return(page1, nil) + + resp, err := h.handler.ListNotes(ctx, connect.NewRequest(&pb.ListNotesRequest{PageSize: 2})) + require.NoError(t, err) + require.Len(t, resp.Msg.GetNotes(), 2) + token := resp.Msg.GetNextPageToken() + require.NotEmpty(t, token, "full page must carry a continuation token") + + // The token round-trips into cursor params for the next page. + wantTime := base.Add(time.Minute) + wantID := int64(2) + h.store.EXPECT().ListNotes(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, params models.ListNotesParams) ([]models.Note, error) { + require.NotNil(t, params.CursorTime) + require.NotNil(t, params.CursorID) + require.True(t, params.CursorTime.Equal(wantTime)) + require.Equal(t, wantID, *params.CursorID) + return []models.Note{mkNote(1, base)}, nil + }) + + resp2, err := h.handler.ListNotes(ctx, connect.NewRequest(&pb.ListNotesRequest{PageSize: 2, PageToken: token})) + require.NoError(t, err) + require.Len(t, resp2.Msg.GetNotes(), 1) + require.Empty(t, resp2.Msg.GetNextPageToken(), "short page means the feed is exhausted") + }) + + t.Run("invalid tokens are InvalidArgument", func(t *testing.T) { + t.Parallel() + h := newTestHandler(t) + ctx := authorCtx(t, 1, 7, "alice") + + badJSON := base64.URLEncoding.EncodeToString([]byte("{not json")) + zeroTime := base64.URLEncoding.EncodeToString([]byte(`{"created_at":"0001-01-01T00:00:00Z","id":3}`)) + badID := base64.URLEncoding.EncodeToString([]byte(`{"created_at":"2026-06-01T12:00:00Z","id":0}`)) + + for name, token := range map[string]string{ + "not base64": "%%%not-base64%%%", + "not json": badJSON, + "zero created_at": zeroTime, + "non-positive id": badID, + } { + _, err := h.handler.ListNotes(ctx, connect.NewRequest(&pb.ListNotesRequest{PageSize: 2, PageToken: token})) + requireCode(t, err, connect.CodeInvalidArgument) + require.Contains(t, connectDebug(t, err), "invalid page token", "case %s", name) + } + }) +} + +func connectDebug(t *testing.T, err error) string { + t.Helper() + var fe fleeterror.FleetError + require.ErrorAs(t, err, &fe) + return fe.DebugMessage +} diff --git a/server/internal/handlers/notes/translate.go b/server/internal/handlers/notes/translate.go new file mode 100644 index 0000000000..259226c927 --- /dev/null +++ b/server/internal/handlers/notes/translate.go @@ -0,0 +1,26 @@ +package notes + +import ( + "google.golang.org/protobuf/types/known/timestamppb" + + pb "github.com/block/proto-fleet/server/generated/grpc/notes/v1" + "github.com/block/proto-fleet/server/internal/domain/notes/models" +) + +func toProtoNote(n *models.Note) *pb.Note { + return &pb.Note{ + Id: n.ID, + Content: n.Content, + AuthorUsername: n.AuthorUsername, + CreatedAt: timestamppb.New(n.CreatedAt), + UpdatedAt: timestamppb.New(n.UpdatedAt), + } +} + +func toListNotesResponse(rows []models.Note, nextPageToken string) *pb.ListNotesResponse { + out := make([]*pb.Note, len(rows)) + for i := range rows { + out[i] = toProtoNote(&rows[i]) + } + return &pb.ListNotesResponse{Notes: out, NextPageToken: nextPageToken} +} diff --git a/server/migrations/000081_create_note_table.down.sql b/server/migrations/000081_create_note_table.down.sql new file mode 100644 index 0000000000..f5aec3eef3 --- /dev/null +++ b/server/migrations/000081_create_note_table.down.sql @@ -0,0 +1,3 @@ +DROP TRIGGER IF EXISTS update_note_updated_at ON note; +DROP INDEX IF EXISTS idx_note_org_feed; +DROP TABLE IF EXISTS note; diff --git a/server/migrations/000081_create_note_table.up.sql b/server/migrations/000081_create_note_table.up.sql new file mode 100644 index 0000000000..6b72e87b60 --- /dev/null +++ b/server/migrations/000081_create_note_table.up.sql @@ -0,0 +1,30 @@ +-- The shared team notepad: one org-wide feed of notes every member can +-- read and post to. Rows are soft-deleted; authorship (user_id) drives +-- the author-only edit/delete rule enforced in the domain layer. +CREATE TABLE note ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + content TEXT NOT NULL, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ NULL, + + CONSTRAINT fk_note_organization FOREIGN KEY (org_id) + REFERENCES organization(id) ON DELETE RESTRICT, + -- Users are soft-deleted, never hard-deleted; RESTRICT keeps a + -- deactivated author's notes attributable. + CONSTRAINT fk_note_user FOREIGN KEY (user_id) + REFERENCES "user"(id) ON DELETE RESTRICT +); + +-- Matches the keyset predicate (org_id, created_at, id) used by ListNotes. +CREATE INDEX idx_note_org_feed + ON note (org_id, created_at DESC, id DESC) + WHERE deleted_at IS NULL; + +CREATE TRIGGER update_note_updated_at + BEFORE UPDATE ON note + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/server/migrations/000082_seed_note_permissions.down.sql b/server/migrations/000082_seed_note_permissions.down.sql new file mode 100644 index 0000000000..055bc99ddd --- /dev/null +++ b/server/migrations/000082_seed_note_permissions.down.sql @@ -0,0 +1,16 @@ +-- Reverses 000081_seed_note_permissions.up.sql by removing the note keys +-- from every role that holds them and then deleting the permission rows +-- themselves. Rolling back the data migration cleanly is impossible +-- without provenance tracking; the rollback path is rare/dev-only and +-- assumes no operator has hand-granted these keys to custom roles. +-- SUPER_ADMIN will re-acquire them at the next boot via the catalog +-- reconciler unless catalog.go is also rolled back. + +DELETE FROM role_permission +WHERE permission_id IN ( + SELECT id FROM permission + WHERE key IN ('note:read', 'note:create', 'note:manage') +); + +DELETE FROM permission +WHERE key IN ('note:read', 'note:create', 'note:manage'); diff --git a/server/migrations/000082_seed_note_permissions.up.sql b/server/migrations/000082_seed_note_permissions.up.sql new file mode 100644 index 0000000000..58a64b5bd7 --- /dev/null +++ b/server/migrations/000082_seed_note_permissions.up.sql @@ -0,0 +1,37 @@ +-- Seed the note:read, note:create, and note:manage permission rows and +-- backfill them onto existing built-in roles. The catalog reconciler +-- upserts new permission rows on startup, but it does NOT re-assert seed +-- permissions onto already-seeded ADMIN/FIELD_TECH roles (additive mode, +-- see reconcile.go). Without this migration, deployments upgraded from a +-- release prior to this one would never grant the new note keys, so the +-- NoteService endpoints would silently deny. +-- +-- SUPER_ADMIN is reconciled in full mode at boot and converges on its +-- own. Unlike the pool/activity seeds, FIELD_TECH IS backfilled here +-- (read + create only): the notepad is an org-shared surface every team +-- member is expected to read and post to. + +INSERT INTO permission (key, description) VALUES + ('note:read', 'View the shared team notepad.'), + ('note:create', 'Add notes to the shared team notepad and edit or delete your own notes.'), + ('note:manage', 'Delete any note on the shared team notepad.') +ON CONFLICT (key) DO UPDATE SET description = EXCLUDED.description; + +-- Scoped to builtin_key so operator-created custom roles aren't touched. +-- ON CONFLICT makes this safe to replay against orgs that already hold +-- any of the keys. +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM role r, permission p +WHERE r.builtin_key = 'ADMIN' + AND r.deleted_at IS NULL + AND p.key IN ('note:read', 'note:create', 'note:manage') +ON CONFLICT (role_id, permission_id) DO NOTHING; + +INSERT INTO role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM role r, permission p +WHERE r.builtin_key = 'FIELD_TECH' + AND r.deleted_at IS NULL + AND p.key IN ('note:read', 'note:create') +ON CONFLICT (role_id, permission_id) DO NOTHING; diff --git a/server/sqlc/queries/note.sql b/server/sqlc/queries/note.sql new file mode 100644 index 0000000000..b5b2d879e8 --- /dev/null +++ b/server/sqlc/queries/note.sql @@ -0,0 +1,51 @@ +-- name: CreateNote :one +INSERT INTO note (org_id, user_id, content) +VALUES (sqlc.arg('org_id'), sqlc.arg('user_id'), sqlc.arg('content')) +RETURNING *; + +-- name: GetNote :one +SELECT * +FROM note +WHERE id = sqlc.arg('id') + AND org_id = sqlc.arg('org_id') + AND deleted_at IS NULL; + +-- name: ListNotes :many +-- Keyset pagination mirrors activity.sql: strict (created_at, id) tuple +-- descent, newest first. The "user" join supplies the display username; +-- it deliberately ignores user.deleted_at so a deactivated author still +-- attributes. +SELECT + n.id, + n.org_id, + n.user_id, + u.username AS author_username, + n.content, + n.created_at, + n.updated_at +FROM note n +JOIN "user" u ON u.id = n.user_id +WHERE n.org_id = sqlc.arg('org_id') + AND n.deleted_at IS NULL + AND (sqlc.narg('cursor_time')::timestamptz IS NULL + OR (n.created_at, n.id) < (sqlc.narg('cursor_time')::timestamptz, sqlc.narg('cursor_id')::bigint)) +ORDER BY n.created_at DESC, n.id DESC +LIMIT sqlc.arg('page_size'); + +-- name: UpdateNoteContent :one +-- The author predicate lives in the WHERE so the ownership check cannot +-- race the domain layer's read; zero rows maps to NotFound at the store. +UPDATE note +SET content = sqlc.arg('content') +WHERE id = sqlc.arg('id') + AND org_id = sqlc.arg('org_id') + AND user_id = sqlc.arg('user_id') + AND deleted_at IS NULL +RETURNING *; + +-- name: SoftDeleteNote :execrows +UPDATE note +SET deleted_at = CURRENT_TIMESTAMP +WHERE id = sqlc.arg('id') + AND org_id = sqlc.arg('org_id') + AND deleted_at IS NULL; From 36e06e97692da4202e8ada5457a5014277b5de51 Mon Sep 17 00:00:00 2001 From: Marvin Charles Date: Thu, 11 Jun 2026 11:44:51 -0500 Subject: [PATCH 2/7] feat(notes): add notepad panel to fleet client A persistent header toggle summons the shared team notepad as a right-side slide-in panel on any view. The panel is non-modal -- no backdrop or scroll lock -- so the page underneath stays interactive while notes are read or written. Composer, author-only inline edit, author-or-moderator delete with confirm, an edited indicator, and cursor-based Load more mirror the server contract. The feed polls only while the panel is open; each tick re-fetches just the first page and merges it in (new rows prepend, edits replace in place, in-window deletions drop) so pages loaded via Load more survive the poll. Surfaces gate on note:read / note:create / note:manage to match the server's any-scope enforcement. --- client/src/protoFleet/api/clients.ts | 3 + client/src/protoFleet/api/notes.ts | 101 +++++++++ .../src/protoFleet/api/useNotesFeed.test.ts | 208 ++++++++++++++++++ client/src/protoFleet/api/useNotesFeed.ts | 174 +++++++++++++++ .../components/AppLayout/AppLayout.tsx | 3 + .../components/PageHeader/PageHeader.test.tsx | 36 +++ .../components/PageHeader/PageHeader.tsx | 23 +- .../features/notes/components/NoteCard.tsx | 163 ++++++++++++++ .../notes/components/NoteComposer.tsx | 66 ++++++ .../notes/components/NotepadPanel.test.tsx | 153 +++++++++++++ .../notes/components/NotepadPanel.tsx | 129 +++++++++++ client/src/protoFleet/features/notes/index.ts | 1 + client/src/protoFleet/store/hooks/useUI.ts | 4 + client/src/protoFleet/store/index.ts | 2 + client/src/protoFleet/store/slices/uiSlice.ts | 10 + 15 files changed, 1073 insertions(+), 3 deletions(-) create mode 100644 client/src/protoFleet/api/notes.ts create mode 100644 client/src/protoFleet/api/useNotesFeed.test.ts create mode 100644 client/src/protoFleet/api/useNotesFeed.ts create mode 100644 client/src/protoFleet/features/notes/components/NoteCard.tsx create mode 100644 client/src/protoFleet/features/notes/components/NoteComposer.tsx create mode 100644 client/src/protoFleet/features/notes/components/NotepadPanel.test.tsx create mode 100644 client/src/protoFleet/features/notes/components/NotepadPanel.tsx create mode 100644 client/src/protoFleet/features/notes/index.ts diff --git a/client/src/protoFleet/api/clients.ts b/client/src/protoFleet/api/clients.ts index f8bc04e28f..ec376c3922 100644 --- a/client/src/protoFleet/api/clients.ts +++ b/client/src/protoFleet/api/clients.ts @@ -12,6 +12,7 @@ import { FleetManagementService } from "@/protoFleet/api/generated/fleetmanageme import { ForemanImportService } from "@/protoFleet/api/generated/foremanimport/v1/foremanimport_pb"; import { MinerCommandService } from "@/protoFleet/api/generated/minercommand/v1/command_pb"; import { NetworkInfoService } from "@/protoFleet/api/generated/networkinfo/v1/networkinfo_pb"; +import { NoteService } from "@/protoFleet/api/generated/notes/v1/notes_pb"; import { OnboardingService } from "@/protoFleet/api/generated/onboarding/v1/onboarding_pb"; import { PairingService } from "@/protoFleet/api/generated/pairing/v1/pairing_pb"; import { PoolsService } from "@/protoFleet/api/generated/pools/v1/pools_pb"; @@ -27,6 +28,7 @@ const authzClient = createClient(AuthzService, transport); const curtailmentClient = createClient(CurtailmentService, transport); const errorQueryClient = createClient(ErrorQueryService, transport); const networkInfoClient = createClient(NetworkInfoService, transport); +const notesClient = createClient(NoteService, transport); const pairingClient = createClient(PairingService, transport); const fleetManagementClient = createClient(FleetManagementService, transport); const onboardingClient = createClient(OnboardingService, transport); @@ -50,6 +52,7 @@ export { deviceSetClient, errorQueryClient, networkInfoClient, + notesClient, pairingClient, fleetManagementClient, onboardingClient, diff --git a/client/src/protoFleet/api/notes.ts b/client/src/protoFleet/api/notes.ts new file mode 100644 index 0000000000..ebb28bff74 --- /dev/null +++ b/client/src/protoFleet/api/notes.ts @@ -0,0 +1,101 @@ +import { useCallback } from "react"; +import { type Code, ConnectError } from "@connectrpc/connect"; + +import { notesClient } from "@/protoFleet/api/clients"; +import { type Note } from "@/protoFleet/api/generated/notes/v1/notes_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +// Content cap mirrors the proto's max_len and the server's post-trim +// recheck; the composer uses it to disable submit before a round trip. +export const MAX_NOTE_CONTENT_LENGTH = 4096; + +interface CreateNoteProps { + content: string; + signal?: AbortSignal; + onSuccess?: (note: Note) => void; + onError?: (message: string, code?: Code) => void; + onFinally?: () => void; +} + +interface UpdateNoteProps { + id: bigint; + content: string; + signal?: AbortSignal; + onSuccess?: (note: Note) => void; + onError?: (message: string, code?: Code) => void; + onFinally?: () => void; +} + +interface DeleteNoteProps { + id: bigint; + signal?: AbortSignal; + onSuccess?: () => void; + onError?: (message: string, code?: Code) => void; + onFinally?: () => void; +} + +// Mutation hooks for the shared team notepad. Feed reads live in +// useNotesFeed, which owns pagination + poll merging; these callbacks +// follow the sites.ts shape so composer/card components can stay +// presentation-only. +export const useNotes = () => { + const { handleAuthErrors } = useAuthErrors(); + + const fail = useCallback( + (err: unknown, onError?: (message: string, code?: Code) => void) => { + handleAuthErrors({ + error: err, + onError: (error) => { + const code = error instanceof ConnectError ? error.code : undefined; + onError?.(getErrorMessage(error), code); + }, + }); + }, + [handleAuthErrors], + ); + + const createNote = useCallback( + async ({ content, signal, onSuccess, onError, onFinally }: CreateNoteProps) => { + try { + const response = await notesClient.createNote({ content }, { signal }); + if (response.note) onSuccess?.(response.note); + } catch (err) { + fail(err, onError); + } finally { + onFinally?.(); + } + }, + [fail], + ); + + const updateNote = useCallback( + async ({ id, content, signal, onSuccess, onError, onFinally }: UpdateNoteProps) => { + try { + const response = await notesClient.updateNote({ id, content }, { signal }); + if (response.note) onSuccess?.(response.note); + } catch (err) { + fail(err, onError); + } finally { + onFinally?.(); + } + }, + [fail], + ); + + const deleteNote = useCallback( + async ({ id, signal, onSuccess, onError, onFinally }: DeleteNoteProps) => { + try { + await notesClient.deleteNote({ id }, { signal }); + onSuccess?.(); + } catch (err) { + fail(err, onError); + } finally { + onFinally?.(); + } + }, + [fail], + ); + + return { createNote, updateNote, deleteNote }; +}; diff --git a/client/src/protoFleet/api/useNotesFeed.test.ts b/client/src/protoFleet/api/useNotesFeed.test.ts new file mode 100644 index 0000000000..5754877544 --- /dev/null +++ b/client/src/protoFleet/api/useNotesFeed.test.ts @@ -0,0 +1,208 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { create } from "@bufbuild/protobuf"; +import { TimestampSchema } from "@bufbuild/protobuf/wkt"; +import { notesClient } from "./clients"; +import { mergeHeadPage, useNotesFeed } from "./useNotesFeed"; +import { ListNotesResponseSchema, type Note, NoteSchema } from "@/protoFleet/api/generated/notes/v1/notes_pb"; + +vi.mock("./clients", () => ({ + notesClient: { + listNotes: vi.fn(), + }, +})); + +const mockHandleAuthErrors = vi.fn(({ onError }) => onError?.(new Error("auth error"))); + +vi.mock("@/protoFleet/store", () => ({ + useAuthErrors: vi.fn(() => ({ + handleAuthErrors: mockHandleAuthErrors, + })), +})); + +// seconds encodes the feed position; id breaks ties exactly like the +// server's (created_at, id) keyset. +function makeNote(id: number, seconds: number, content = `note ${id}`): Note { + return create(NoteSchema, { + id: BigInt(id), + content, + authorUsername: "alice", + createdAt: create(TimestampSchema, { seconds: BigInt(seconds), nanos: 0 }), + updatedAt: create(TimestampSchema, { seconds: BigInt(seconds), nanos: 0 }), + }); +} + +function mockListResponse(notes: Note[], nextPageToken = "") { + return create(ListNotesResponseSchema, { notes, nextPageToken }); +} + +describe("mergeHeadPage", () => { + // Feed order is newest-first; the head page covers ids 5..3. + const held = [makeNote(5, 500), makeNote(4, 400), makeNote(3, 300), makeNote(2, 200), makeNote(1, 100)]; + + it("prepends rows strictly newer than the current head", () => { + const head = [makeNote(6, 600), makeNote(5, 500), makeNote(4, 400)]; + const merged = mergeHeadPage(held, head); + expect(merged.map((n) => n.id)).toEqual([6n, 5n, 4n, 3n, 2n, 1n]); + }); + + it("replaces held copies with the head's version to pick up edits", () => { + const edited = makeNote(4, 400, "edited content"); + const head = [makeNote(5, 500), edited, makeNote(3, 300)]; + const merged = mergeHeadPage(held, head); + expect(merged.find((n) => n.id === 4n)?.content).toBe("edited content"); + expect(merged.map((n) => n.id)).toEqual([5n, 4n, 3n, 2n, 1n]); + }); + + it("drops held rows inside the head window that the head no longer carries", () => { + // Note 4 was deleted upstream: the head window now spans 5..2. + const head = [makeNote(5, 500), makeNote(3, 300), makeNote(2, 200)]; + const merged = mergeHeadPage(held, head); + expect(merged.map((n) => n.id)).toEqual([5n, 3n, 2n, 1n]); + }); + + it("keeps rows older than the head window untouched", () => { + const head = [makeNote(5, 500), makeNote(4, 400)]; + const merged = mergeHeadPage(held, head); + expect(merged.map((n) => n.id)).toEqual([5n, 4n, 3n, 2n, 1n]); + }); + + it("breaks created_at ties by id like the server keyset", () => { + // Held rows 3 and 2 share a timestamp; the window floor is (300, 3), + // so id 2 (same time, lower id) is older-than-window and survives. + const tiedHeld = [makeNote(3, 300), makeNote(2, 300), makeNote(1, 100)]; + const head = [makeNote(4, 300), makeNote(3, 300)]; + const merged = mergeHeadPage(tiedHeld, head); + expect(merged.map((n) => n.id)).toEqual([4n, 3n, 2n, 1n]); + }); + + it("returns an empty feed when the head page is empty", () => { + expect(mergeHeadPage(held, [])).toEqual([]); + }); +}); + +describe("useNotesFeed", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // The hook deliberately has no fetch-on-mount: the panel drives the + // initial load through usePoll when it opens. Tests stand in for the + // panel by calling refresh() explicitly. + it("refresh loads the first page and reports hasLoaded", async () => { + vi.mocked(notesClient.listNotes).mockResolvedValue(mockListResponse([makeNote(2, 200), makeNote(1, 100)])); + + const { result } = renderHook(() => useNotesFeed({ pageSize: 25 })); + expect(result.current.hasLoaded).toBe(false); + + act(() => { + result.current.refresh(); + }); + + await waitFor(() => { + expect(result.current.hasLoaded).toBe(true); + }); + + expect(notesClient.listNotes).toHaveBeenCalledWith({ pageSize: 25, pageToken: "" }); + expect(result.current.notes).toHaveLength(2); + expect(result.current.hasMore).toBe(false); + }); + + it("loadMore appends the next page using the returned token", async () => { + vi.mocked(notesClient.listNotes) + .mockResolvedValueOnce(mockListResponse([makeNote(2, 200)], "token-2")) + .mockResolvedValueOnce(mockListResponse([makeNote(1, 100)], "")); + + const { result } = renderHook(() => useNotesFeed({ pageSize: 1 })); + + act(() => { + result.current.refresh(); + }); + + await waitFor(() => { + expect(result.current.hasMore).toBe(true); + }); + + act(() => { + result.current.loadMore(); + }); + + await waitFor(() => { + expect(result.current.notes).toHaveLength(2); + }); + + expect(notesClient.listNotes).toHaveBeenLastCalledWith({ pageSize: 1, pageToken: "token-2" }); + expect(result.current.hasMore).toBe(false); + expect(result.current.notes.map((n) => n.id)).toEqual([2n, 1n]); + }); + + it("refreshHead merges the head page without collapsing loaded pages", async () => { + vi.mocked(notesClient.listNotes) + .mockResolvedValueOnce(mockListResponse([makeNote(3, 300), makeNote(2, 200)], "token-2")) + .mockResolvedValueOnce(mockListResponse([makeNote(1, 100)], "")) + // Poll tick: note 4 arrived and note 3 was deleted upstream, so + // the fresh head window spans (400 .. 200). Note 1 sits below the + // window and must survive untouched. + .mockResolvedValueOnce(mockListResponse([makeNote(4, 400), makeNote(2, 200)])); + + const { result } = renderHook(() => useNotesFeed({ pageSize: 2 })); + + act(() => { + result.current.refresh(); + }); + await waitFor(() => { + expect(result.current.hasMore).toBe(true); + }); + act(() => { + result.current.loadMore(); + }); + await waitFor(() => { + expect(result.current.notes).toHaveLength(3); + }); + + await act(async () => { + await result.current.refreshHead(); + }); + + expect(result.current.notes.map((n) => n.id)).toEqual([4n, 2n, 1n]); + expect(result.current.hasMore).toBe(false); + }); + + it("an empty head page clears the feed and the cursor", async () => { + vi.mocked(notesClient.listNotes) + .mockResolvedValueOnce(mockListResponse([makeNote(1, 100)], "token-x")) + .mockResolvedValueOnce(mockListResponse([])); + + const { result } = renderHook(() => useNotesFeed({ pageSize: 1 })); + + act(() => { + result.current.refresh(); + }); + await waitFor(() => { + expect(result.current.hasMore).toBe(true); + }); + + await act(async () => { + await result.current.refreshHead(); + }); + + expect(result.current.notes).toEqual([]); + expect(result.current.hasMore).toBe(false); + }); + + it("surfaces list errors through the shared auth handler", async () => { + vi.mocked(notesClient.listNotes).mockRejectedValue(new Error("boom")); + + const { result } = renderHook(() => useNotesFeed()); + + act(() => { + result.current.refresh(); + }); + + await waitFor(() => { + expect(result.current.error).not.toBeNull(); + }); + expect(mockHandleAuthErrors).toHaveBeenCalled(); + expect(result.current.hasLoaded).toBe(false); + }); +}); diff --git a/client/src/protoFleet/api/useNotesFeed.ts b/client/src/protoFleet/api/useNotesFeed.ts new file mode 100644 index 0000000000..80f16c4dcb --- /dev/null +++ b/client/src/protoFleet/api/useNotesFeed.ts @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { notesClient } from "@/protoFleet/api/clients"; +import { type Note } from "@/protoFleet/api/generated/notes/v1/notes_pb"; +import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; +import { useAuthErrors } from "@/protoFleet/store"; + +interface UseNotesFeedParams { + pageSize?: number; +} + +interface UseNotesFeedResult { + notes: Note[]; + isLoading: boolean; + // True once any fetch has succeeded — distinguishes "loading the + // feed for the first time" (spinner) from "feed is genuinely empty". + hasLoaded: boolean; + error: string | null; + hasMore: boolean; + loadMore: () => void; + refresh: () => void; + refreshHead: () => Promise; +} + +// Compare two notes by the server feed order: (created_at, id) +// descending. Positive when a sorts after b in the feed (i.e. a is +// older). Compares the raw Timestamp fields so sub-millisecond +// distinctions survive (Date would truncate to ms). +const feedCmp = (a: Note, b: Note): number => { + const at = a.createdAt; + const bt = b.createdAt; + const as = at?.seconds ?? 0n; + const bs = bt?.seconds ?? 0n; + if (as !== bs) return as < bs ? 1 : -1; + const an = at?.nanos ?? 0; + const bn = bt?.nanos ?? 0; + if (an !== bn) return an < bn ? 1 : -1; + if (a.id !== b.id) return a.id < b.id ? 1 : -1; + return 0; +}; + +// mergeHeadPage folds a freshly fetched first page into the +// accumulated feed without collapsing pages loaded via Load more. +// +// The head page is authoritative for its own window (everything at or +// newer than its oldest row): rows it carries replace held copies +// (picking up edits), rows it doesn't carry were deleted upstream and +// drop out. Held rows older than the window are kept untouched — +// stale until the next full refresh, which is normal feed behavior. +// An empty head page means the feed itself is empty. +export const mergeHeadPage = (prev: Note[], head: Note[]): Note[] => { + if (head.length === 0) return []; + const windowFloor = head[head.length - 1]; + const headIds = new Set(head.map((n) => n.id)); + const olderThanWindow = prev.filter((n) => !headIds.has(n.id) && feedCmp(n, windowFloor) > 0); + return [...head, ...olderThanWindow]; +}; + +// Feed state for the shared team notepad: cursor accumulation + +// Load more mirroring useActivity, plus refreshHead() for the poll +// tick so the visible top of the feed stays live without resetting +// scroll position or loaded pages. +export function useNotesFeed({ pageSize = 25 }: UseNotesFeedParams = {}): UseNotesFeedResult { + const { handleAuthErrors } = useAuthErrors(); + + const [notes, setNotes] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const [error, setError] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [pageToken, setPageToken] = useState(""); + + const requestIdRef = useRef(0); + + const fetchNotes = useCallback( + async (token: string, append: boolean) => { + const requestId = ++requestIdRef.current; + setIsLoading(true); + setError(null); + + try { + const response = await notesClient.listNotes({ pageSize, pageToken: token }); + if (requestId !== requestIdRef.current) return; + + const { notes: newNotes, nextPageToken } = response; + if (append) { + setNotes((prev) => [...prev, ...newNotes]); + } else { + setNotes(newNotes); + } + setPageToken(nextPageToken); + setHasMore(nextPageToken !== ""); + setHasLoaded(true); + } catch (err) { + if (requestId !== requestIdRef.current) return; + handleAuthErrors({ + error: err, + onError: (e) => { + setError(getErrorMessage(e, "Failed to load notes")); + }, + }); + } finally { + if (requestId === requestIdRef.current) { + setIsLoading(false); + } + } + }, + [pageSize, handleAuthErrors], + ); + + // Ref-based stability (same pattern as useActivity.ts). + const fetchRef = useRef(fetchNotes); + useEffect(() => { + fetchRef.current = fetchNotes; + }, [fetchNotes]); + + const pageTokenRef = useRef(pageToken); + useEffect(() => { + pageTokenRef.current = pageToken; + }, [pageToken]); + + const isLoadingRef = useRef(isLoading); + useEffect(() => { + isLoadingRef.current = isLoading; + }, [isLoading]); + + const hasMoreRef = useRef(hasMore); + useEffect(() => { + hasMoreRef.current = hasMore; + }, [hasMore]); + + const pageSizeRef = useRef(pageSize); + useEffect(() => { + pageSizeRef.current = pageSize; + }, [pageSize]); + + const loadMore = useCallback(() => { + if (hasMoreRef.current && !isLoadingRef.current) { + void fetchRef.current(pageTokenRef.current, true); + } + }, []); + + const refresh = useCallback(() => { + if (isLoadingRef.current) return; + setNotes([]); + setPageToken(""); + setHasMore(false); + void fetchRef.current("", false); + }, []); + + // refreshHead is the poll tick: re-fetch page 1 only and merge it + // into the accumulated list. It deliberately bypasses the + // isLoading/cursor state so an in-flight Load more and a poll can't + // corrupt each other — the merge is associative with appends. + const refreshHead = useCallback(async () => { + try { + const response = await notesClient.listNotes({ pageSize: pageSizeRef.current, pageToken: "" }); + const head = response.notes; + setNotes((prev) => mergeHeadPage(prev, head)); + setHasLoaded(true); + if (head.length === 0) { + // Feed emptied upstream: any held cursor points at deleted rows. + setPageToken(""); + setHasMore(false); + } + } catch (err) { + // Poll-tick failures are deliberately silent: the feed keeps its + // last-good rows and the next tick retries. Auth errors still + // route through the shared handler so an expired session logs out. + handleAuthErrors({ error: err, onError: () => undefined }); + } + }, [handleAuthErrors]); + + return { notes, isLoading, hasLoaded, error, hasMore, loadMore, refresh, refreshHead }; +} diff --git a/client/src/protoFleet/components/AppLayout/AppLayout.tsx b/client/src/protoFleet/components/AppLayout/AppLayout.tsx index 2c7e099027..2c046c0a58 100644 --- a/client/src/protoFleet/components/AppLayout/AppLayout.tsx +++ b/client/src/protoFleet/components/AppLayout/AppLayout.tsx @@ -7,6 +7,7 @@ import PageHeader from "@/protoFleet/components/PageHeader"; import { useCurtailmentPillData } from "@/protoFleet/components/PageHeader/useCurtailmentPillData"; import { useSchedulePillData } from "@/protoFleet/components/PageHeader/useSchedulePillData"; import { primaryNavItems } from "@/protoFleet/config/navItems"; +import { NotepadPanel } from "@/protoFleet/features/notes"; import { usePageBackground } from "@/protoFleet/hooks/usePageBackground"; import { useReactiveLocalStorage } from "@/shared/hooks/useReactiveLocalStorage"; import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; @@ -54,6 +55,8 @@ const AppLayoutContent = ({ children }: Props) => { > {children} + + ); }; diff --git a/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx b/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx index 2b223072f3..c3cc4ed17d 100644 --- a/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx +++ b/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx @@ -43,12 +43,17 @@ vi.mock("@/shared/hooks/useReactiveLocalStorage", () => ({ useReactiveLocalStorage: () => mockUseReactiveLocalStorage(), })); +const mockSetNotepadOpen = vi.fn(); + vi.mock("@/protoFleet/store", () => ({ useHasPermission: vi.fn(), + useIsNotepadOpen: vi.fn(() => false), + useSetNotepadOpen: vi.fn(() => mockSetNotepadOpen), })); vi.mock("@/shared/assets/icons", () => ({ Pause: ({ ariaLabel }: { ariaLabel?: string }) => , + Edit: () => , })); const createPillSchedule = (name: string): ScheduleListItem => ({ @@ -161,4 +166,35 @@ describe("PageHeader", () => { expect(screen.queryByText("Curtailment pill")).not.toBeInTheDocument(); expect(mockCurtailmentPill).not.toHaveBeenCalled(); }); + + it("shows the notepad toggle with note:read and forwards clicks to the store", () => { + mockUseWindowDimensions.mockReturnValue({ + isPhone: false, + isTablet: false, + }); + vi.mocked(useHasPermission).mockImplementation((key: string) => key === "note:read"); + + render( + + + , + ); + + const toggle = screen.getByTestId("notepad-toggle"); + expect(toggle).toBeVisible(); + toggle.click(); + expect(mockSetNotepadOpen).toHaveBeenCalledWith(true); + }); + + it("hides the notepad toggle without note:read", () => { + vi.mocked(useHasPermission).mockReturnValue(false); + + render( + + + , + ); + + expect(screen.queryByTestId("notepad-toggle")).not.toBeInTheDocument(); + }); }); diff --git a/client/src/protoFleet/components/PageHeader/PageHeader.tsx b/client/src/protoFleet/components/PageHeader/PageHeader.tsx index d02581653d..21f8ecc36e 100644 --- a/client/src/protoFleet/components/PageHeader/PageHeader.tsx +++ b/client/src/protoFleet/components/PageHeader/PageHeader.tsx @@ -11,8 +11,8 @@ import { type SiteWithCounts } from "@/protoFleet/api/generated/sites/v1/sites_p import { useSites } from "@/protoFleet/api/sites"; import { MULTI_SITE_ENABLED } from "@/protoFleet/constants/featureFlags"; import { usePageBackground } from "@/protoFleet/hooks/usePageBackground"; -import { useHasPermission } from "@/protoFleet/store"; -import { Pause } from "@/shared/assets/icons"; +import { useHasPermission, useIsNotepadOpen, useSetNotepadOpen } from "@/protoFleet/store"; +import { Edit, Pause } from "@/shared/assets/icons"; import Button, { sizes, variants } from "@/shared/components/Button"; import { useReactiveLocalStorage } from "@/shared/hooks/useReactiveLocalStorage"; import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; @@ -76,6 +76,9 @@ function PageHeader({ const [dismissedSetup, setDismissedSetup] = useReactiveLocalStorage("completeSetupDismissed"); const hasDismissedSetup = Boolean(dismissedSetup); const canReadCurtailment = useHasPermission("curtailment:read"); + const canReadNotes = useHasPermission("note:read"); + const isNotepadOpen = useIsNotepadOpen(); + const setNotepadOpen = useSetNotepadOpen(); // Multi-site: the SitePicker replaces today's LocationSelector when the // feature flag is on. Sites are fetched once on mount and held here so the @@ -143,7 +146,21 @@ function PageHeader({ )} - {!isPhone && headerWidgetEnabled ? : null} +
+ {!isPhone && headerWidgetEnabled ? : null} + {canReadNotes ? ( +
{showPhoneWidgets ? ( diff --git a/client/src/protoFleet/features/notes/components/NoteCard.tsx b/client/src/protoFleet/features/notes/components/NoteCard.tsx new file mode 100644 index 0000000000..b63679f1b1 --- /dev/null +++ b/client/src/protoFleet/features/notes/components/NoteCard.tsx @@ -0,0 +1,163 @@ +import { type ReactElement, useState } from "react"; +import { timestampDate } from "@bufbuild/protobuf/wkt"; + +import { type Note } from "@/protoFleet/api/generated/notes/v1/notes_pb"; +import { MAX_NOTE_CONTENT_LENGTH, useNotes } from "@/protoFleet/api/notes"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Dialog from "@/shared/components/Dialog"; +import Textarea from "@/shared/components/Textarea"; + +interface NoteCardProps { + note: Note; + // Own-note affordances key off the username match for display; the + // server enforces real authorship from the session either way. + isOwn: boolean; + canModerate: boolean; + onChanged: () => void; +} + +// updated_at > created_at is the wire-level "edited" signal: content +// edits are the only mutation a live note receives. +const isEdited = (note: Note): boolean => { + const created = note.createdAt; + const updated = note.updatedAt; + if (!created || !updated) return false; + if (updated.seconds !== created.seconds) return updated.seconds > created.seconds; + return updated.nanos > created.nanos; +}; + +const formatCreatedAt = (note: Note): string => (note.createdAt ? timestampDate(note.createdAt).toLocaleString() : ""); + +const NoteCard = ({ note, isOwn, canModerate, onChanged }: NoteCardProps): ReactElement => { + const { updateNote, deleteNote } = useNotes(); + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(note.content); + const [isConfirmingDelete, setIsConfirmingDelete] = useState(false); + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + + const trimmedDraft = draft.trim(); + const canSave = !isPending && trimmedDraft !== "" && trimmedDraft.length <= MAX_NOTE_CONTENT_LENGTH; + + const startEditing = () => { + setDraft(note.content); + setError(null); + setIsEditing(true); + }; + + const saveEdit = () => { + if (!canSave) return; + setIsPending(true); + void updateNote({ + id: note.id, + content: trimmedDraft, + onSuccess: () => { + setIsEditing(false); + onChanged(); + }, + onError: (message) => setError(message), + onFinally: () => setIsPending(false), + }); + }; + + const confirmDelete = () => { + setIsPending(true); + void deleteNote({ + id: note.id, + onSuccess: () => { + setIsConfirmingDelete(false); + onChanged(); + }, + onError: (message) => { + setIsConfirmingDelete(false); + setError(message); + }, + onFinally: () => setIsPending(false), + }); + }; + + return ( +
+
+ {note.authorUsername} + + {formatCreatedAt(note)} + {isEdited(note) ? " (edited)" : null} + +
+ + {isEditing ? ( +
+