From f59fd3368cb0659dae4109b0195109591b3d10c6 Mon Sep 17 00:00:00 2001 From: Julian V Date: Wed, 15 Jul 2026 16:46:27 +0200 Subject: [PATCH 01/10] Implement logbook sharing --- app/api/shared/logbooks/[sheetId]/route.ts | 11 +++ app/components/LogbookApp.tsx | 12 ++++ .../logbook/pages/LogbookDetailsPage.tsx | 44 ++++++++++++ app/lib/db/logbook-database.ts | 40 ++++++++++- .../db/migrations/020_log_sheet_sharing.sql | 9 +++ app/lib/db/schema.sql | 9 ++- app/lib/logbook-store.ts | 6 ++ app/lib/repositories/log-sheets-repository.ts | 27 ++++++- app/models/log-sheet.ts | 23 ++++++ app/models/logbook-rows.ts | 10 ++- app/models/logbook.ts | 3 +- app/share/[sheetId]/page.tsx | 71 +++++++++++++++++++ .../log-sheets-repository.test.ts | 4 +- 13 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 app/api/shared/logbooks/[sheetId]/route.ts create mode 100644 app/lib/db/migrations/020_log_sheet_sharing.sql create mode 100644 app/share/[sheetId]/page.tsx diff --git a/app/api/shared/logbooks/[sheetId]/route.ts b/app/api/shared/logbooks/[sheetId]/route.ts new file mode 100644 index 0000000..92f0e1c --- /dev/null +++ b/app/api/shared/logbooks/[sheetId]/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { auth } from "../../../../../auth"; +import { readSharedLogSheet } from "../../../../lib/logbook-store"; + +export async function GET(_request: Request, { params }: { params: Promise<{ sheetId: string }> }) { + const { sheetId } = await params; + const session = await auth(); + const sharedSheet = await readSharedLogSheet(sheetId, Boolean(session?.user?.id)); + if (!sharedSheet) return NextResponse.json({ error: "Shared logbook not found" }, { status: 404 }); + return NextResponse.json(sharedSheet); +} diff --git a/app/components/LogbookApp.tsx b/app/components/LogbookApp.tsx index 6e3c61c..e7fb05a 100644 --- a/app/components/LogbookApp.tsx +++ b/app/components/LogbookApp.tsx @@ -12,6 +12,7 @@ import { type LineForm, type LogLine, type LogSheet, + type LogSheetShareSettings, type PersistedLogbook, type SheetForm, } from "../models/logbook"; @@ -1032,6 +1033,16 @@ export function LogbookApp({ setSheetForm(sheetToForm(sheet)); } + async function updateActiveSheetShare(share: LogSheetShareSettings) { + const nextLogbook = { + ...logbookRef.current, + sheets: logbookRef.current.sheets.map((sheet) => + sheet.id === activeSheet.id ? { ...sheet, share } : sheet, + ), + }; + await saveLogbookNow(nextLogbook); + } + async function updateActiveSheetStatus(status: LogSheet["status"]) { if (status === "Locked") cancelSheetInlineEdit(); const nextLogbook = { @@ -1697,6 +1708,7 @@ export function LogbookApp({ renderInlineTextField={renderInlineTextField} isActiveSheetLocked={isActiveSheetLocked} updateActiveSheetStatus={updateActiveSheetStatus} + updateActiveSheetShare={updateActiveSheetShare} renderInlineBoatField={renderInlineBoatField} activeBoat={activeBoat} renderInlineDateField={renderInlineDateField} diff --git a/app/components/logbook/pages/LogbookDetailsPage.tsx b/app/components/logbook/pages/LogbookDetailsPage.tsx index 025b735..81cd885 100644 --- a/app/components/logbook/pages/LogbookDetailsPage.tsx +++ b/app/components/logbook/pages/LogbookDetailsPage.tsx @@ -7,6 +7,7 @@ import type { SheetForm, LogLine, LogSheet, + LogSheetShareSettings, PersistedLogbook, } from "../../../models/logbook"; import { coordinateToInput, decimalToDmsParts, dmsPartsToDecimal, parseCoordinate, type CoordinateFormat, type DmsParts } from "../../../domain/nautical/coordinates"; @@ -16,6 +17,7 @@ import { updateLogLineFormForInput } from "../../../domain/log-lines/log-line-ed import { LogLinesMapView } from "../OpenSeaMapView"; import type { TranslationKey } from "../../../lib/i18n"; import { fileToStoredImage } from "../image-utils"; +import { defaultLogSheetShareSettings } from "../../../models/logbook"; type CourseColumn = { field: keyof Pick< @@ -72,6 +74,7 @@ export function LogbookDetailsPage(props: LogbookDetailsPageProps) { renderInlineTextField, isActiveSheetLocked, updateActiveSheetStatus, + updateActiveSheetShare, renderInlineBoatField, renderInlineDateField, activeSheetSummary, @@ -93,6 +96,7 @@ export function LogbookDetailsPage(props: LogbookDetailsPageProps) { deleteTechnicalCheck, } = props; const activeBoat = props.activeBoat as Boat; + const updateShare = updateActiveSheetShare as (share: LogSheetShareSettings) => Promise; const activeSheet = props.activeSheet as LogSheet; const lineForm = props.lineForm as LineForm; const logbook = props.logbook as PersistedLogbook; @@ -109,6 +113,11 @@ export function LogbookDetailsPage(props: LogbookDetailsPageProps) { const [courseTooltipPosition, setCourseTooltipPosition] = useState({ left: 0, top: 0 }); const technicalCheckDrafts = technicalCheckDraftState.sheetId === activeSheet.id ? technicalCheckDraftState.drafts : {}; const scannerWarnings = activeSheet.scannerWarnings ?? []; + const share = activeSheet.share ?? defaultLogSheetShareSettings; + const shareUrl = typeof window === "undefined" ? `/share/${activeSheet.id}` : `${window.location.origin}/share/${activeSheet.id}`; + const setShare = (patch: Partial) => { + void updateShare({ ...share, ...patch }); + }; const showScannerDraftNotice = activeSheet.source === "scanner" && activeSheet.status === "Draft"; const courseConversionSequence = useRef(0); @@ -630,6 +639,41 @@ export function LogbookDetailsPage(props: LogbookDetailsPageProps) { )} + +
+

Sharing

+ + {share.privacy !== "private" ? ( +

Public URL: {shareUrl}

+ ) :

Set privacy to public or registered users to enable a share URL.

} +
+ {[ + ["includeMasterData", "Master data (from/to/boat)"], + ["includePicture", "Picture"], + ["includeLogLines", "Loglines"], + ["includeTechnicalLog", "Technical log"], + ["includeSkipper", "Skipper"], + ["includeCrew", "Crew information"], + ].map(([field, label]) => ( + + ))} +
+
+
{ + await this.ensureSchemaAndBackfill(); + const sharedRow = await this.sheets.findSharedByUnscopedId(sheetId); + if (!sharedRow?.owner_id) return undefined; + const originalOwnerId = this.ownerId; + try { + this.ownerId = sharedRow.owner_id; + const logbook = await this.readTables(); + const sheet = logbook.sheets.find((candidate) => candidate.id === sheetId); + if (!sheet) return undefined; + const share = sheet.share ?? defaultLogSheetShareSettings; + if (share.privacy === "private") return undefined; + if (share.privacy === "registered" && !isAuthenticated) return undefined; + const boat = logbook.boats.find((candidate) => candidate.id === sheet.boatId); + return { sheet: filterSharedSheet(sheet), boatName: share.includeMasterData ? boat?.name ?? "" : "" }; + } finally { + this.ownerId = originalOwnerId; + } + } + protected async readTables(): Promise { const [boats, sheets, crewProfiles, crew, lines] = await Promise.all([ this.boats.findAll(this.ownerId), @@ -77,3 +97,21 @@ export abstract class LogbookDatabase implements QueryableDatabase { await this.crew.ensurePrimaryProfile(this.ownerId); } } + +function filterSharedSheet(sheet: LogSheet): LogSheet { + const share = sheet.share ?? defaultLogSheetShareSettings; + const crew = share.includeCrew + ? sheet.crew.filter((_, index) => share.includeSkipper || index !== 0) + : share.includeSkipper && sheet.crew[0] + ? [sheet.crew[0]] + : []; + return { + ...sheet, + boatId: share.includeMasterData ? sheet.boatId : "", + route: share.includeMasterData ? sheet.route : { from: "", to: "", departed: "", arrived: "" }, + image: share.includePicture ? sheet.image : undefined, + lines: share.includeLogLines ? sheet.lines : [], + technicalChecks: share.includeTechnicalLog ? sheet.technicalChecks : [], + crew, + }; +} diff --git a/app/lib/db/migrations/020_log_sheet_sharing.sql b/app/lib/db/migrations/020_log_sheet_sharing.sql new file mode 100644 index 0000000..981a051 --- /dev/null +++ b/app/lib/db/migrations/020_log_sheet_sharing.sql @@ -0,0 +1,9 @@ +alter table log_sheets add column share_privacy text not null default 'private'; +alter table log_sheets add column share_master_data integer not null default 1; +alter table log_sheets add column share_picture integer not null default 1; +alter table log_sheets add column share_loglines integer not null default 1; +alter table log_sheets add column share_technical_log integer not null default 1; +alter table log_sheets add column share_skipper integer not null default 1; +alter table log_sheets add column share_crew integer not null default 1; + +create index if not exists log_sheets_share_privacy_idx on log_sheets (share_privacy); diff --git a/app/lib/db/schema.sql b/app/lib/db/schema.sql index 308ba73..8776d43 100644 --- a/app/lib/db/schema.sql +++ b/app/lib/db/schema.sql @@ -70,7 +70,14 @@ create table if not exists boats ( image_data text, image_mime_type text, image_width integer, - image_height integer + image_height integer, + share_privacy text not null default 'private', + share_master_data integer not null default 1, + share_picture integer not null default 1, + share_loglines integer not null default 1, + share_technical_log integer not null default 1, + share_skipper integer not null default 1, + share_crew integer not null default 1 ); create table if not exists log_sheets ( diff --git a/app/lib/logbook-store.ts b/app/lib/logbook-store.ts index d3b34a1..27f53b3 100644 --- a/app/lib/logbook-store.ts +++ b/app/lib/logbook-store.ts @@ -25,3 +25,9 @@ export async function writeLogbook(logbook: PersistedLogbook, userId = "legacy-u writeQueue = operation.then(() => undefined, () => undefined); return operation; } + +export async function readSharedLogSheet(sheetId: string, isAuthenticated: boolean) { + const operation = writeQueue.then(() => getDatabase().readSharedSheet(sheetId, isAuthenticated)); + writeQueue = operation.then(() => undefined, () => undefined); + return operation; +} diff --git a/app/lib/repositories/log-sheets-repository.ts b/app/lib/repositories/log-sheets-repository.ts index 8771d54..6a2d5b0 100644 --- a/app/lib/repositories/log-sheets-repository.ts +++ b/app/lib/repositories/log-sheets-repository.ts @@ -1,4 +1,4 @@ -import { normalizeDeviationTable, type Boat, type BoatRow, type CrewMemberRow, type LogLineRow, type LogSheet, type LogSheetRow, type PersistedLogbook, type StoredLogSheet } from "../../models/logbook"; +import { defaultLogSheetShareSettings, normalizeDeviationTable, type Boat, type BoatRow, type CrewMemberRow, type LogLineRow, type LogSheet, type LogSheetRow, type PersistedLogbook, type StoredLogSheet } from "../../models/logbook"; import type { QueryableDatabase } from "../db/logbook-database"; import { imageFromRow, imageValues, scopedId, unscopedId } from "./boats-repository"; @@ -9,14 +9,18 @@ export class LogSheetsRepository { return (await this.db.query(`select * from log_sheets where owner_id = ${this.db.placeholder(1)} order by date_range desc, title`, [ownerId])).rows; } + async findSharedByUnscopedId(sheetId: string) { + return (await this.db.query(`select * from log_sheets where (id = ${this.db.placeholder(1)} or id like ${this.db.placeholder(2)}) and share_privacy <> 'private' limit 1`, [sheetId, `%:${sheetId}`])).rows[0]; + } + async deleteAll(ownerId = "legacy-user") { await this.db.query(`delete from log_sheets where owner_id = ${this.db.placeholder(1)}`, [ownerId]); } async insert(sheet: LogSheet, ownerId = "legacy-user") { await this.db.query( - `insert into log_sheets (id, title, date_range, status, source, verification_note, scanner_warnings, boat_id, skipper, route, weather_briefing, day_summary, remarks, watch_plan, technical_checks, image_data, image_mime_type, image_width, image_height, owner_id) values (${this.values(20)})`, - [scopedId(ownerId, sheet.id), sheet.title, sheet.dateRange, sheet.status, sheet.source ?? null, sheet.verificationNote ?? null, sheet.scannerWarnings ? JSON.stringify(sheet.scannerWarnings) : null, scopedId(ownerId, sheet.boatId), JSON.stringify({}), JSON.stringify(sheet.route), JSON.stringify({}), JSON.stringify({}), JSON.stringify([]), JSON.stringify(sheet.watchPlan), JSON.stringify(sheet.technicalChecks), ...imageValues(sheet.image), ownerId], + `insert into log_sheets (id, title, date_range, status, source, verification_note, scanner_warnings, boat_id, skipper, route, weather_briefing, day_summary, remarks, watch_plan, technical_checks, image_data, image_mime_type, image_width, image_height, owner_id, share_privacy, share_master_data, share_picture, share_loglines, share_technical_log, share_skipper, share_crew) values (${this.values(27)})`, + [scopedId(ownerId, sheet.id), sheet.title, sheet.dateRange, sheet.status, sheet.source ?? null, sheet.verificationNote ?? null, sheet.scannerWarnings ? JSON.stringify(sheet.scannerWarnings) : null, scopedId(ownerId, sheet.boatId), JSON.stringify({}), JSON.stringify(sheet.route), JSON.stringify({}), JSON.stringify({}), JSON.stringify([]), JSON.stringify(sheet.watchPlan), JSON.stringify(sheet.technicalChecks), ...imageValues(sheet.image), ownerId, sheet.share?.privacy ?? defaultLogSheetShareSettings.privacy, boolValue(sheet.share?.includeMasterData ?? defaultLogSheetShareSettings.includeMasterData), boolValue(sheet.share?.includePicture ?? defaultLogSheetShareSettings.includePicture), boolValue(sheet.share?.includeLogLines ?? defaultLogSheetShareSettings.includeLogLines), boolValue(sheet.share?.includeTechnicalLog ?? defaultLogSheetShareSettings.includeTechnicalLog), boolValue(sheet.share?.includeSkipper ?? defaultLogSheetShareSettings.includeSkipper), boolValue(sheet.share?.includeCrew ?? defaultLogSheetShareSettings.includeCrew)], ); } @@ -103,5 +107,22 @@ function mapStoredSheet(sheet: LogSheetRow): StoredLogSheet { watchPlan: parseJson(sheet.watch_plan), technicalChecks: parseJson(sheet.technical_checks), ...(imageFromRow(sheet) ? { image: imageFromRow(sheet) } : {}), + share: { + privacy: sheet.share_privacy ?? defaultLogSheetShareSettings.privacy, + includeMasterData: boolFromRow(sheet.share_master_data, defaultLogSheetShareSettings.includeMasterData), + includePicture: boolFromRow(sheet.share_picture, defaultLogSheetShareSettings.includePicture), + includeLogLines: boolFromRow(sheet.share_loglines, defaultLogSheetShareSettings.includeLogLines), + includeTechnicalLog: boolFromRow(sheet.share_technical_log, defaultLogSheetShareSettings.includeTechnicalLog), + includeSkipper: boolFromRow(sheet.share_skipper, defaultLogSheetShareSettings.includeSkipper), + includeCrew: boolFromRow(sheet.share_crew, defaultLogSheetShareSettings.includeCrew), + }, }; } + +function boolValue(value: boolean) { + return value ? 1 : 0; +} + +function boolFromRow(value: number | null | undefined, fallback: boolean) { + return value == null ? fallback : Boolean(value); +} diff --git a/app/models/log-sheet.ts b/app/models/log-sheet.ts index ad26fd2..7dd3300 100644 --- a/app/models/log-sheet.ts +++ b/app/models/log-sheet.ts @@ -2,6 +2,28 @@ import type { SheetCrewMember } from "./crew-member"; import type { StoredImage } from "./stored-image"; import type { LogLine } from "./log-line"; +export type LogSheetSharePrivacy = "private" | "registered" | "public"; + +export type LogSheetShareSettings = { + privacy: LogSheetSharePrivacy; + includeMasterData: boolean; + includePicture: boolean; + includeLogLines: boolean; + includeTechnicalLog: boolean; + includeSkipper: boolean; + includeCrew: boolean; +}; + +export const defaultLogSheetShareSettings: LogSheetShareSettings = { + privacy: "private", + includeMasterData: true, + includePicture: true, + includeLogLines: true, + includeTechnicalLog: true, + includeSkipper: true, + includeCrew: true, +}; + export type LogSheet = { id: string; title: string; @@ -22,4 +44,5 @@ export type LogSheet = { technicalChecks: string[]; image?: StoredImage; lines: LogLine[]; + share?: LogSheetShareSettings; }; diff --git a/app/models/logbook-rows.ts b/app/models/logbook-rows.ts index 5133823..35149a0 100644 --- a/app/models/logbook-rows.ts +++ b/app/models/logbook-rows.ts @@ -1,4 +1,4 @@ -import type { Boat, CrewMember, LogLine, LogSheet, SheetCrewMember } from "./logbook"; +import type { Boat, CrewMember, LogLine, LogSheet, LogSheetSharePrivacy, SheetCrewMember } from "./logbook"; export type StoredLogSheet = Omit; @@ -32,6 +32,14 @@ export type LogSheetRow = ImageRowFields & { remarks: unknown; watch_plan: unknown; technical_checks: unknown; + share_privacy?: LogSheetSharePrivacy | null; + share_master_data?: number | null; + share_picture?: number | null; + share_loglines?: number | null; + share_technical_log?: number | null; + share_skipper?: number | null; + share_crew?: number | null; + owner_id?: string; }; export type CrewMemberRow = Omit & ImageRowFields & { diff --git a/app/models/logbook.ts b/app/models/logbook.ts index da9589e..d3787a6 100644 --- a/app/models/logbook.ts +++ b/app/models/logbook.ts @@ -12,7 +12,8 @@ export type { Boat, BoatType, DeviationTableRow } from "./boat"; export type { StoredImage } from "./stored-image"; export type { CrewMember, SheetCrewMember } from "./crew-member"; export type { LogLine, TemperatureUnit, WindUnit } from "./log-line"; -export type { LogSheet } from "./log-sheet"; +export type { LogSheet, LogSheetSharePrivacy, LogSheetShareSettings } from "./log-sheet"; +export { defaultLogSheetShareSettings } from "./log-sheet"; export type { BoatForm, CrewForm, LineForm, SheetForm } from "./logbook-forms"; export type { ScannerResult, ScannedLogLine, ScannedLogSheetDraft } from "./logbook-scanner"; diff --git a/app/share/[sheetId]/page.tsx b/app/share/[sheetId]/page.tsx new file mode 100644 index 0000000..d90f38d --- /dev/null +++ b/app/share/[sheetId]/page.tsx @@ -0,0 +1,71 @@ +import { auth } from "../../../auth"; +import { readSharedLogSheet } from "../../lib/logbook-store"; +import { EntityImage } from "../../components/logbook/EntityImage"; + +export default async function SharedLogbookPage({ params }: { params: Promise<{ sheetId: string }> }) { + const { sheetId } = await params; + const session = await auth(); + const shared = await readSharedLogSheet(sheetId, Boolean(session?.user?.id)); + + if (!shared) { + return ( +
+
+
+

Shared logbook

+

Logbook not available

+

This logbook is private, restricted to registered users, or does not exist.

+
+
+
+ ); + } + + const { sheet, boatName } = shared; + const skipper = sheet.crew[0]; + + return ( +
+
+
+ {sheet.image ? : null} +
+

Shared logbook

+

{sheet.title}

+

{sheet.dateRange}

+
+ {(boatName || sheet.route.from || sheet.route.to) && ( +
+ {boatName ?
Boat{boatName}
: null} + {(sheet.route.departed || sheet.route.from) ?
From{sheet.route.departed}{sheet.route.from}
: null} + {(sheet.route.arrived || sheet.route.to) ?
To{sheet.route.arrived}{sheet.route.to}
: null} +
+ )} +
+ + {skipper ?

Skipper

{skipper.name}

: null} + + {sheet.crew.length > (skipper ? 1 : 0) ? ( +
+

Crew

+
    {sheet.crew.slice(skipper ? 1 : 0).map((crew, index) =>
  • {crew.name} · {crew.role}
  • )}
+
+ ) : null} + + {sheet.lines.length ? ( +
+

Log lines

+
+ + + {sheet.lines.map((line, index) => )} +
TimeLatLonWeatherWindLogRemarks
{line.time}{line.latitude}{line.longitude}{line.weather} {line.weatherRemark}{line.windDirection} {line.windStrength} {line.windUnit}{line.logNm} nm{line.remarks}
+
+
+ ) : null} + + {sheet.technicalChecks.length ?

Technical log

    {sheet.technicalChecks.map((item, index) =>
  • {item}
  • )}
: null} +
+
+ ); +} diff --git a/tests/app/lib/repositories/log-sheets-repository.test.ts b/tests/app/lib/repositories/log-sheets-repository.test.ts index 518041b..63bc9aa 100644 --- a/tests/app/lib/repositories/log-sheets-repository.test.ts +++ b/tests/app/lib/repositories/log-sheets-repository.test.ts @@ -50,7 +50,7 @@ describe("LogSheetsRepository", () => { await new LogSheetsRepository(db).insert(sheet); expect(db.calls[0].sql).toContain("insert into log_sheets"); - expect(db.calls[0].values).toEqual([`legacy-user:${sheet.id}`, sheet.title, sheet.dateRange, sheet.status, null, null, null, `legacy-user:${sheet.boatId}`, JSON.stringify({}), JSON.stringify(sheet.route), JSON.stringify({}), JSON.stringify({}), JSON.stringify([]), JSON.stringify(sheet.watchPlan), JSON.stringify(sheet.technicalChecks), null, null, null, null, "legacy-user"]); + expect(db.calls[0].values).toEqual([`legacy-user:${sheet.id}`, sheet.title, sheet.dateRange, sheet.status, null, null, null, `legacy-user:${sheet.boatId}`, JSON.stringify({}), JSON.stringify(sheet.route), JSON.stringify({}), JSON.stringify({}), JSON.stringify([]), JSON.stringify(sheet.watchPlan), JSON.stringify(sheet.technicalChecks), null, null, null, null, "legacy-user", "private", 1, 1, 1, 1, 1, 1]); }); it("maps relational rows back to a persisted logbook", () => { @@ -62,7 +62,7 @@ describe("LogSheetsRepository", () => { expect(LogSheetsRepository.toLogbook([boatRow], [sheetRow], [crewRow], [lineRow])).toEqual({ boats: [boat], crewMembers: [], - sheets: [{ ...sheet, crew: [{ ...crew, isPrimary: false }], lines: [line] }], + sheets: [{ ...sheet, share: { privacy: "private", includeMasterData: true, includePicture: true, includeLogLines: true, includeTechnicalLog: true, includeSkipper: true, includeCrew: true }, crew: [{ ...crew, isPrimary: false }], lines: [line] }], }); }); From 15b1ee996fa49da046cae9130b880ad78ce593b7 Mon Sep 17 00:00:00 2001 From: Julian V Date: Thu, 16 Jul 2026 05:44:49 +0200 Subject: [PATCH 02/10] Optimize shared logbook loading --- .../logbooks/[ownerId]/[sheetId]/route.ts | 11 ++++++ app/components/LogbookApp.tsx | 1 + .../logbook/pages/LogbookDetailsPage.tsx | 4 ++- app/lib/db/logbook-database.ts | 36 ++++++++++--------- app/lib/db/schema.sql | 20 ++++++----- app/lib/logbook-store.ts | 4 +-- app/lib/repositories/boats-repository.ts | 4 +++ app/lib/repositories/crew-repository.ts | 28 +++++++++++++++ app/lib/repositories/log-lines-repository.ts | 4 +++ app/lib/repositories/log-sheets-repository.ts | 4 +++ app/share/[ownerId]/[sheetId]/page.tsx | 6 ++++ app/share/[sheetId]/page.tsx | 6 ++-- 12 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 app/api/shared/logbooks/[ownerId]/[sheetId]/route.ts create mode 100644 app/share/[ownerId]/[sheetId]/page.tsx diff --git a/app/api/shared/logbooks/[ownerId]/[sheetId]/route.ts b/app/api/shared/logbooks/[ownerId]/[sheetId]/route.ts new file mode 100644 index 0000000..ebfa778 --- /dev/null +++ b/app/api/shared/logbooks/[ownerId]/[sheetId]/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from "next/server"; +import { auth } from "../../../../../../auth"; +import { readSharedLogSheet } from "../../../../../lib/logbook-store"; + +export async function GET(_request: Request, { params }: { params: Promise<{ ownerId: string; sheetId: string }> }) { + const { ownerId, sheetId } = await params; + const session = await auth(); + const sharedSheet = await readSharedLogSheet(sheetId, Boolean(session?.user?.id), ownerId); + if (!sharedSheet) return NextResponse.json({ error: "Shared logbook not found" }, { status: 404 }); + return NextResponse.json(sharedSheet); +} diff --git a/app/components/LogbookApp.tsx b/app/components/LogbookApp.tsx index e7fb05a..f66cc9a 100644 --- a/app/components/LogbookApp.tsx +++ b/app/components/LogbookApp.tsx @@ -1705,6 +1705,7 @@ export function LogbookApp({ navigate={navigate} cancelSheetEdit={cancelSheetEdit} activeSheet={activeSheet} + userId={userId} renderInlineTextField={renderInlineTextField} isActiveSheetLocked={isActiveSheetLocked} updateActiveSheetStatus={updateActiveSheetStatus} diff --git a/app/components/logbook/pages/LogbookDetailsPage.tsx b/app/components/logbook/pages/LogbookDetailsPage.tsx index 81cd885..8517442 100644 --- a/app/components/logbook/pages/LogbookDetailsPage.tsx +++ b/app/components/logbook/pages/LogbookDetailsPage.tsx @@ -96,6 +96,7 @@ export function LogbookDetailsPage(props: LogbookDetailsPageProps) { deleteTechnicalCheck, } = props; const activeBoat = props.activeBoat as Boat; + const sharingOwnerId = props.userId as string | undefined; const updateShare = updateActiveSheetShare as (share: LogSheetShareSettings) => Promise; const activeSheet = props.activeSheet as LogSheet; const lineForm = props.lineForm as LineForm; @@ -114,7 +115,8 @@ export function LogbookDetailsPage(props: LogbookDetailsPageProps) { const technicalCheckDrafts = technicalCheckDraftState.sheetId === activeSheet.id ? technicalCheckDraftState.drafts : {}; const scannerWarnings = activeSheet.scannerWarnings ?? []; const share = activeSheet.share ?? defaultLogSheetShareSettings; - const shareUrl = typeof window === "undefined" ? `/share/${activeSheet.id}` : `${window.location.origin}/share/${activeSheet.id}`; + const sharePath = sharingOwnerId ? `/share/${encodeURIComponent(sharingOwnerId)}/${encodeURIComponent(activeSheet.id)}` : `/share/${encodeURIComponent(activeSheet.id)}`; + const shareUrl = typeof window === "undefined" ? sharePath : `${window.location.origin}${sharePath}`; const setShare = (patch: Partial) => { void updateShare({ ...share, ...patch }); }; diff --git a/app/lib/db/logbook-database.ts b/app/lib/db/logbook-database.ts index 8b6a56d..6491b7b 100644 --- a/app/lib/db/logbook-database.ts +++ b/app/lib/db/logbook-database.ts @@ -2,6 +2,7 @@ import { defaultLogSheetShareSettings, type LogSheet, type PersistedLogbook } fr import { BoatsRepository } from "../repositories/boats-repository"; import { CrewRepository } from "../repositories/crew-repository"; import { LogLinesRepository } from "../repositories/log-lines-repository"; +import { scopedId } from "../repositories/boats-repository"; import { LogSheetsRepository } from "../repositories/log-sheets-repository"; import { backfillCrewMemberEncryption } from "./encryption-backfill"; @@ -53,24 +54,27 @@ export abstract class LogbookDatabase implements QueryableDatabase { return logbook; } - async readSharedSheet(sheetId: string, isAuthenticated: boolean): Promise<{ sheet: LogSheet; boatName: string } | undefined> { + async readSharedSheet(sheetId: string, isAuthenticated: boolean, ownerId?: string): Promise<{ sheet: LogSheet; boatName: string } | undefined> { await this.ensureSchemaAndBackfill(); - const sharedRow = await this.sheets.findSharedByUnscopedId(sheetId); + const sharedRow = ownerId + ? await this.sheets.findSharedByScopedId(scopedId(ownerId, sheetId)) + : await this.sheets.findSharedByUnscopedId(sheetId); if (!sharedRow?.owner_id) return undefined; - const originalOwnerId = this.ownerId; - try { - this.ownerId = sharedRow.owner_id; - const logbook = await this.readTables(); - const sheet = logbook.sheets.find((candidate) => candidate.id === sheetId); - if (!sheet) return undefined; - const share = sheet.share ?? defaultLogSheetShareSettings; - if (share.privacy === "private") return undefined; - if (share.privacy === "registered" && !isAuthenticated) return undefined; - const boat = logbook.boats.find((candidate) => candidate.id === sheet.boatId); - return { sheet: filterSharedSheet(sheet), boatName: share.includeMasterData ? boat?.name ?? "" : "" }; - } finally { - this.ownerId = originalOwnerId; - } + + const share = LogSheetsRepository.toLogbook([], [sharedRow], [], []).sheets[0]?.share ?? defaultLogSheetShareSettings; + if (share.privacy === "private") return undefined; + if (share.privacy === "registered" && !isAuthenticated) return undefined; + + const [boatRow, crewRows, lineRows] = await Promise.all([ + share.includeMasterData ? this.boats.findByScopedId(sharedRow.boat_id) : undefined, + (share.includeSkipper || share.includeCrew) ? this.crew.findForSheet(sharedRow.id, sharedRow.owner_id) : [], + share.includeLogLines ? this.lines.findForSheet(sharedRow.id) : [], + ]); + const logbook = LogSheetsRepository.toLogbook(boatRow ? [boatRow] : [], [sharedRow], crewRows, lineRows); + const sheet = logbook.sheets[0]; + if (!sheet) return undefined; + const boat = logbook.boats.find((candidate) => candidate.id === sheet.boatId); + return { sheet: filterSharedSheet(sheet), boatName: share.includeMasterData ? boat?.name ?? "" : "" }; } protected async readTables(): Promise { diff --git a/app/lib/db/schema.sql b/app/lib/db/schema.sql index 8776d43..e8d6f52 100644 --- a/app/lib/db/schema.sql +++ b/app/lib/db/schema.sql @@ -70,14 +70,7 @@ create table if not exists boats ( image_data text, image_mime_type text, image_width integer, - image_height integer, - share_privacy text not null default 'private', - share_master_data integer not null default 1, - share_picture integer not null default 1, - share_loglines integer not null default 1, - share_technical_log integer not null default 1, - share_skipper integer not null default 1, - share_crew integer not null default 1 + image_height integer ); create table if not exists log_sheets ( @@ -100,9 +93,18 @@ create table if not exists log_sheets ( image_data text, image_mime_type text, image_width integer, - image_height integer + image_height integer, + share_privacy text not null default 'private', + share_master_data integer not null default 1, + share_picture integer not null default 1, + share_loglines integer not null default 1, + share_technical_log integer not null default 1, + share_skipper integer not null default 1, + share_crew integer not null default 1 ); +create index if not exists log_sheets_share_privacy_idx on log_sheets (share_privacy); + create table if not exists crew_members ( id text primary key, name text not null, diff --git a/app/lib/logbook-store.ts b/app/lib/logbook-store.ts index 27f53b3..af62c93 100644 --- a/app/lib/logbook-store.ts +++ b/app/lib/logbook-store.ts @@ -26,8 +26,8 @@ export async function writeLogbook(logbook: PersistedLogbook, userId = "legacy-u return operation; } -export async function readSharedLogSheet(sheetId: string, isAuthenticated: boolean) { - const operation = writeQueue.then(() => getDatabase().readSharedSheet(sheetId, isAuthenticated)); +export async function readSharedLogSheet(sheetId: string, isAuthenticated: boolean, ownerId?: string) { + const operation = writeQueue.then(() => getDatabase().readSharedSheet(sheetId, isAuthenticated, ownerId)); writeQueue = operation.then(() => undefined, () => undefined); return operation; } diff --git a/app/lib/repositories/boats-repository.ts b/app/lib/repositories/boats-repository.ts index 946165a..211b901 100644 --- a/app/lib/repositories/boats-repository.ts +++ b/app/lib/repositories/boats-repository.ts @@ -8,6 +8,10 @@ export class BoatsRepository { return (await this.db.query(`select * from boats where owner_id = ${this.db.placeholder(1)} order by name`, [ownerId])).rows; } + async findByScopedId(id: string) { + return (await this.db.query(`select * from boats where id = ${this.db.placeholder(1)} limit 1`, [id])).rows[0]; + } + async deleteAll(ownerId = "legacy-user") { await this.db.query(`delete from boats where owner_id = ${this.db.placeholder(1)}`, [ownerId]); } diff --git a/app/lib/repositories/crew-repository.ts b/app/lib/repositories/crew-repository.ts index 63e0a41..aca9914 100644 --- a/app/lib/repositories/crew-repository.ts +++ b/app/lib/repositories/crew-repository.ts @@ -44,6 +44,34 @@ export class CrewRepository { `, [ownerId])).rows.map((row) => this.decryptCrewRow(row, ownerId)); } + async findForSheet(sheetScopedId: string, ownerId = "legacy-user") { + return (await this.db.query(` + select + sheet_crew_members.sheet_id, + sheet_crew_members.crew_member_id, + sheet_crew_members.sort_order, + crew_members.id, + crew_members.name, + crew_members.nationality, + crew_members.role, + crew_members.address, + crew_members.certificate, + crew_members.is_primary, + crew_members.image_data, + crew_members.image_mime_type, + crew_members.image_width, + crew_members.image_height, + sheet_crew_members.embarkation_datetime, + sheet_crew_members.embarkation_position, + sheet_crew_members.disembarkation_datetime, + sheet_crew_members.disembarkation_position + from sheet_crew_members + join crew_members on crew_members.id = sheet_crew_members.crew_member_id + where sheet_crew_members.sheet_id = ${this.db.placeholder(1)} + order by sheet_crew_members.sort_order + `, [sheetScopedId])).rows.map((row) => this.decryptCrewRow(row, ownerId)); + } + async deleteAll(ownerId = "legacy-user") { await this.db.query(`delete from sheet_crew_members where sheet_id in (select id from log_sheets where owner_id = ${this.db.placeholder(1)})`, [ownerId]); await this.db.query(`delete from crew_members where owner_id = ${this.db.placeholder(1)}`, [ownerId]); diff --git a/app/lib/repositories/log-lines-repository.ts b/app/lib/repositories/log-lines-repository.ts index c74da76..7ee0817 100644 --- a/app/lib/repositories/log-lines-repository.ts +++ b/app/lib/repositories/log-lines-repository.ts @@ -9,6 +9,10 @@ export class LogLinesRepository { return (await this.db.query(`select log_lines.* from log_lines join log_sheets on log_sheets.id = log_lines.sheet_id where log_sheets.owner_id = ${this.db.placeholder(1)} order by log_lines.sheet_id, time, sort_order`, [ownerId])).rows; } + async findForSheet(sheetScopedId: string) { + return (await this.db.query(`select * from log_lines where sheet_id = ${this.db.placeholder(1)} order by time, sort_order`, [sheetScopedId])).rows; + } + async deleteAll(ownerId = "legacy-user") { await this.db.query(`delete from log_lines where sheet_id in (select id from log_sheets where owner_id = ${this.db.placeholder(1)})`, [ownerId]); } diff --git a/app/lib/repositories/log-sheets-repository.ts b/app/lib/repositories/log-sheets-repository.ts index 6a2d5b0..b58063e 100644 --- a/app/lib/repositories/log-sheets-repository.ts +++ b/app/lib/repositories/log-sheets-repository.ts @@ -9,6 +9,10 @@ export class LogSheetsRepository { return (await this.db.query(`select * from log_sheets where owner_id = ${this.db.placeholder(1)} order by date_range desc, title`, [ownerId])).rows; } + async findSharedByScopedId(scopedSheetId: string) { + return (await this.db.query(`select * from log_sheets where id = ${this.db.placeholder(1)} and share_privacy <> 'private' limit 1`, [scopedSheetId])).rows[0]; + } + async findSharedByUnscopedId(sheetId: string) { return (await this.db.query(`select * from log_sheets where (id = ${this.db.placeholder(1)} or id like ${this.db.placeholder(2)}) and share_privacy <> 'private' limit 1`, [sheetId, `%:${sheetId}`])).rows[0]; } diff --git a/app/share/[ownerId]/[sheetId]/page.tsx b/app/share/[ownerId]/[sheetId]/page.tsx new file mode 100644 index 0000000..75bd154 --- /dev/null +++ b/app/share/[ownerId]/[sheetId]/page.tsx @@ -0,0 +1,6 @@ +import SharedLogbookPage from "../../[sheetId]/page"; + +export default async function OwnerScopedSharedLogbookPage({ params }: { params: Promise<{ ownerId: string; sheetId: string }> }) { + const { ownerId, sheetId } = await params; + return ; +} diff --git a/app/share/[sheetId]/page.tsx b/app/share/[sheetId]/page.tsx index d90f38d..03b11d3 100644 --- a/app/share/[sheetId]/page.tsx +++ b/app/share/[sheetId]/page.tsx @@ -2,10 +2,10 @@ import { auth } from "../../../auth"; import { readSharedLogSheet } from "../../lib/logbook-store"; import { EntityImage } from "../../components/logbook/EntityImage"; -export default async function SharedLogbookPage({ params }: { params: Promise<{ sheetId: string }> }) { - const { sheetId } = await params; +export default async function SharedLogbookPage({ params }: { params: Promise<{ sheetId: string; ownerId?: string }> }) { + const { sheetId, ownerId } = await params; const session = await auth(); - const shared = await readSharedLogSheet(sheetId, Boolean(session?.user?.id)); + const shared = await readSharedLogSheet(sheetId, Boolean(session?.user?.id), ownerId); if (!shared) { return ( From 418896d52695b92711bb1b9c48928bb3f1402db4 Mon Sep 17 00:00:00 2001 From: Julian V Date: Thu, 16 Jul 2026 16:58:38 +0200 Subject: [PATCH 03/10] Fix shared route slug conflict --- .../shared/logbooks/[[...segments]]/route.ts | 20 +++++++++++++++++++ .../logbooks/[ownerId]/[sheetId]/route.ts | 11 ---------- app/api/shared/logbooks/[sheetId]/route.ts | 11 ---------- .../{[sheetId] => [[...segments]]}/page.tsx | 13 +++++++++--- app/share/[ownerId]/[sheetId]/page.tsx | 6 ------ 5 files changed, 30 insertions(+), 31 deletions(-) create mode 100644 app/api/shared/logbooks/[[...segments]]/route.ts delete mode 100644 app/api/shared/logbooks/[ownerId]/[sheetId]/route.ts delete mode 100644 app/api/shared/logbooks/[sheetId]/route.ts rename app/share/{[sheetId] => [[...segments]]}/page.tsx (88%) delete mode 100644 app/share/[ownerId]/[sheetId]/page.tsx diff --git a/app/api/shared/logbooks/[[...segments]]/route.ts b/app/api/shared/logbooks/[[...segments]]/route.ts new file mode 100644 index 0000000..f8eb6f4 --- /dev/null +++ b/app/api/shared/logbooks/[[...segments]]/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { auth } from "../../../../../auth"; +import { readSharedLogSheet } from "../../../../lib/logbook-store"; + +export async function GET(_request: Request, { params }: { params: Promise<{ segments?: string[] }> }) { + const { segments = [] } = await params; + const { ownerId, sheetId } = parseShareSegments(segments); + if (!sheetId) return NextResponse.json({ error: "Shared logbook not found" }, { status: 404 }); + + const session = await auth(); + const sharedSheet = await readSharedLogSheet(sheetId, Boolean(session?.user?.id), ownerId); + if (!sharedSheet) return NextResponse.json({ error: "Shared logbook not found" }, { status: 404 }); + return NextResponse.json(sharedSheet); +} + +function parseShareSegments(segments: string[]) { + if (segments.length === 1) return { sheetId: segments[0] }; + if (segments.length === 2) return { ownerId: segments[0], sheetId: segments[1] }; + return {}; +} diff --git a/app/api/shared/logbooks/[ownerId]/[sheetId]/route.ts b/app/api/shared/logbooks/[ownerId]/[sheetId]/route.ts deleted file mode 100644 index ebfa778..0000000 --- a/app/api/shared/logbooks/[ownerId]/[sheetId]/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "../../../../../../auth"; -import { readSharedLogSheet } from "../../../../../lib/logbook-store"; - -export async function GET(_request: Request, { params }: { params: Promise<{ ownerId: string; sheetId: string }> }) { - const { ownerId, sheetId } = await params; - const session = await auth(); - const sharedSheet = await readSharedLogSheet(sheetId, Boolean(session?.user?.id), ownerId); - if (!sharedSheet) return NextResponse.json({ error: "Shared logbook not found" }, { status: 404 }); - return NextResponse.json(sharedSheet); -} diff --git a/app/api/shared/logbooks/[sheetId]/route.ts b/app/api/shared/logbooks/[sheetId]/route.ts deleted file mode 100644 index 92f0e1c..0000000 --- a/app/api/shared/logbooks/[sheetId]/route.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "../../../../../auth"; -import { readSharedLogSheet } from "../../../../lib/logbook-store"; - -export async function GET(_request: Request, { params }: { params: Promise<{ sheetId: string }> }) { - const { sheetId } = await params; - const session = await auth(); - const sharedSheet = await readSharedLogSheet(sheetId, Boolean(session?.user?.id)); - if (!sharedSheet) return NextResponse.json({ error: "Shared logbook not found" }, { status: 404 }); - return NextResponse.json(sharedSheet); -} diff --git a/app/share/[sheetId]/page.tsx b/app/share/[[...segments]]/page.tsx similarity index 88% rename from app/share/[sheetId]/page.tsx rename to app/share/[[...segments]]/page.tsx index 03b11d3..96b751c 100644 --- a/app/share/[sheetId]/page.tsx +++ b/app/share/[[...segments]]/page.tsx @@ -2,10 +2,11 @@ import { auth } from "../../../auth"; import { readSharedLogSheet } from "../../lib/logbook-store"; import { EntityImage } from "../../components/logbook/EntityImage"; -export default async function SharedLogbookPage({ params }: { params: Promise<{ sheetId: string; ownerId?: string }> }) { - const { sheetId, ownerId } = await params; +export default async function SharedLogbookPage({ params }: { params: Promise<{ segments?: string[] }> }) { + const { segments = [] } = await params; + const { ownerId, sheetId } = parseShareSegments(segments); const session = await auth(); - const shared = await readSharedLogSheet(sheetId, Boolean(session?.user?.id), ownerId); + const shared = sheetId ? await readSharedLogSheet(sheetId, Boolean(session?.user?.id), ownerId) : undefined; if (!shared) { return ( @@ -69,3 +70,9 @@ export default async function SharedLogbookPage({ params }: { params: Promise<{ ); } + +function parseShareSegments(segments: string[]) { + if (segments.length === 1) return { sheetId: segments[0] }; + if (segments.length === 2) return { ownerId: segments[0], sheetId: segments[1] }; + return {}; +} diff --git a/app/share/[ownerId]/[sheetId]/page.tsx b/app/share/[ownerId]/[sheetId]/page.tsx deleted file mode 100644 index 75bd154..0000000 --- a/app/share/[ownerId]/[sheetId]/page.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import SharedLogbookPage from "../../[sheetId]/page"; - -export default async function OwnerScopedSharedLogbookPage({ params }: { params: Promise<{ ownerId: string; sheetId: string }> }) { - const { ownerId, sheetId } = await params; - return ; -} From 14649d271c8a37133989ec82ac012da0e49d4294 Mon Sep 17 00:00:00 2001 From: Julian V Date: Thu, 16 Jul 2026 17:40:01 +0200 Subject: [PATCH 04/10] Refine logsheet sharing controls --- .../logbook/pages/LogbookDetailsPage.tsx | 80 +++++++++++-------- app/lib/db/logbook-database.ts | 48 +++++++---- .../db/migrations/020_log_sheet_sharing.sql | 12 +-- app/lib/db/schema.sql | 12 +-- app/lib/repositories/log-sheets-repository.ts | 32 +++++--- app/models/log-sheet.ts | 26 +++--- app/models/logbook-rows.ts | 12 +-- .../log-sheets-repository.test.ts | 4 +- 8 files changed, 132 insertions(+), 94 deletions(-) diff --git a/app/components/logbook/pages/LogbookDetailsPage.tsx b/app/components/logbook/pages/LogbookDetailsPage.tsx index 8517442..20ceedb 100644 --- a/app/components/logbook/pages/LogbookDetailsPage.tsx +++ b/app/components/logbook/pages/LogbookDetailsPage.tsx @@ -108,6 +108,7 @@ export function LogbookDetailsPage(props: LogbookDetailsPageProps) { const onCoordinateFormatChange = props.onCoordinateFormatChange as (format: CoordinateFormat) => void; const onShowCourseColumnsChange = props.onShowCourseColumnsChange as (show: boolean) => void; const [isMapExpanded, setIsMapExpanded] = useState(false); + const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); const [newTechnicalCheck, setNewTechnicalCheck] = useState(""); const [technicalCheckDraftState, setTechnicalCheckDraftState] = useState<{ sheetId: string; drafts: Record }>({ sheetId: "", drafts: {} }); const [openCourseTooltip, setOpenCourseTooltip] = useState(null); @@ -120,6 +121,15 @@ export function LogbookDetailsPage(props: LogbookDetailsPageProps) { const setShare = (patch: Partial) => { void updateShare({ ...share, ...patch }); }; + const isSharingEnabled = Object.values(share).some((privacy) => privacy !== "private"); + const shareOptions = [ + ["masterData", "Master data (from/to/boat)"], + ["picture", "Picture"], + ["logLines", "Loglines"], + ["technicalLog", "Technical log"], + ["skipper", "Skipper"], + ["crew", "Crew information"], + ] as const; const showScannerDraftNotice = activeSheet.source === "scanner" && activeSheet.status === "Draft"; const courseConversionSequence = useRef(0); @@ -548,6 +558,15 @@ export function LogbookDetailsPage(props: LogbookDetailsPageProps) {
{activeSheet.status} + {isActiveSheetLocked ? ( +
+ {isSharingEnabled ? ( +

Share URL: {shareUrl}

+ ) : ( +

Set at least one part to public or registered users to enable this share link.

+ )} +
+ {shareOptions.map(([field, label]) => ( + + ))} +
+ -
+ )}
candidate.id === sheet.boatId); - return { sheet: filterSharedSheet(sheet), boatName: share.includeMasterData ? boat?.name ?? "" : "" }; + return { sheet: filterSharedSheet(sheet, visibility), boatName: visibility.masterData ? boat?.name ?? "" : "" }; } protected async readTables(): Promise { @@ -102,20 +102,36 @@ export abstract class LogbookDatabase implements QueryableDatabase { } } -function filterSharedSheet(sheet: LogSheet): LogSheet { - const share = sheet.share ?? defaultLogSheetShareSettings; - const crew = share.includeCrew - ? sheet.crew.filter((_, index) => share.includeSkipper || index !== 0) - : share.includeSkipper && sheet.crew[0] +type SectionVisibility = Record, boolean>; + +function sectionVisibility(share: NonNullable, isAuthenticated: boolean): SectionVisibility { + return { + masterData: canViewSection(share.masterData, isAuthenticated), + picture: canViewSection(share.picture, isAuthenticated), + logLines: canViewSection(share.logLines, isAuthenticated), + technicalLog: canViewSection(share.technicalLog, isAuthenticated), + skipper: canViewSection(share.skipper, isAuthenticated), + crew: canViewSection(share.crew, isAuthenticated), + }; +} + +function canViewSection(privacy: LogSheet["share"] extends infer Share ? Share extends undefined ? never : Share[keyof Share] : never, isAuthenticated: boolean) { + return privacy === "public" || (privacy === "registered" && isAuthenticated); +} + +function filterSharedSheet(sheet: LogSheet, visibility: SectionVisibility): LogSheet { + const crew = visibility.crew + ? sheet.crew.filter((_, index) => visibility.skipper || index !== 0) + : visibility.skipper && sheet.crew[0] ? [sheet.crew[0]] : []; return { ...sheet, - boatId: share.includeMasterData ? sheet.boatId : "", - route: share.includeMasterData ? sheet.route : { from: "", to: "", departed: "", arrived: "" }, - image: share.includePicture ? sheet.image : undefined, - lines: share.includeLogLines ? sheet.lines : [], - technicalChecks: share.includeTechnicalLog ? sheet.technicalChecks : [], + boatId: visibility.masterData ? sheet.boatId : "", + route: visibility.masterData ? sheet.route : { from: "", to: "", departed: "", arrived: "" }, + image: visibility.picture ? sheet.image : undefined, + lines: visibility.logLines ? sheet.lines : [], + technicalChecks: visibility.technicalLog ? sheet.technicalChecks : [], crew, }; } diff --git a/app/lib/db/migrations/020_log_sheet_sharing.sql b/app/lib/db/migrations/020_log_sheet_sharing.sql index 981a051..ea9a7bd 100644 --- a/app/lib/db/migrations/020_log_sheet_sharing.sql +++ b/app/lib/db/migrations/020_log_sheet_sharing.sql @@ -1,9 +1,9 @@ alter table log_sheets add column share_privacy text not null default 'private'; -alter table log_sheets add column share_master_data integer not null default 1; -alter table log_sheets add column share_picture integer not null default 1; -alter table log_sheets add column share_loglines integer not null default 1; -alter table log_sheets add column share_technical_log integer not null default 1; -alter table log_sheets add column share_skipper integer not null default 1; -alter table log_sheets add column share_crew integer not null default 1; +alter table log_sheets add column share_master_data text not null default 'private'; +alter table log_sheets add column share_picture text not null default 'private'; +alter table log_sheets add column share_loglines text not null default 'private'; +alter table log_sheets add column share_technical_log text not null default 'private'; +alter table log_sheets add column share_skipper text not null default 'private'; +alter table log_sheets add column share_crew text not null default 'private'; create index if not exists log_sheets_share_privacy_idx on log_sheets (share_privacy); diff --git a/app/lib/db/schema.sql b/app/lib/db/schema.sql index e8d6f52..b660f7a 100644 --- a/app/lib/db/schema.sql +++ b/app/lib/db/schema.sql @@ -95,12 +95,12 @@ create table if not exists log_sheets ( image_width integer, image_height integer, share_privacy text not null default 'private', - share_master_data integer not null default 1, - share_picture integer not null default 1, - share_loglines integer not null default 1, - share_technical_log integer not null default 1, - share_skipper integer not null default 1, - share_crew integer not null default 1 + share_master_data text not null default 'private', + share_picture text not null default 'private', + share_loglines text not null default 'private', + share_technical_log text not null default 'private', + share_skipper text not null default 'private', + share_crew text not null default 'private' ); create index if not exists log_sheets_share_privacy_idx on log_sheets (share_privacy); diff --git a/app/lib/repositories/log-sheets-repository.ts b/app/lib/repositories/log-sheets-repository.ts index b58063e..066e14f 100644 --- a/app/lib/repositories/log-sheets-repository.ts +++ b/app/lib/repositories/log-sheets-repository.ts @@ -24,7 +24,7 @@ export class LogSheetsRepository { async insert(sheet: LogSheet, ownerId = "legacy-user") { await this.db.query( `insert into log_sheets (id, title, date_range, status, source, verification_note, scanner_warnings, boat_id, skipper, route, weather_briefing, day_summary, remarks, watch_plan, technical_checks, image_data, image_mime_type, image_width, image_height, owner_id, share_privacy, share_master_data, share_picture, share_loglines, share_technical_log, share_skipper, share_crew) values (${this.values(27)})`, - [scopedId(ownerId, sheet.id), sheet.title, sheet.dateRange, sheet.status, sheet.source ?? null, sheet.verificationNote ?? null, sheet.scannerWarnings ? JSON.stringify(sheet.scannerWarnings) : null, scopedId(ownerId, sheet.boatId), JSON.stringify({}), JSON.stringify(sheet.route), JSON.stringify({}), JSON.stringify({}), JSON.stringify([]), JSON.stringify(sheet.watchPlan), JSON.stringify(sheet.technicalChecks), ...imageValues(sheet.image), ownerId, sheet.share?.privacy ?? defaultLogSheetShareSettings.privacy, boolValue(sheet.share?.includeMasterData ?? defaultLogSheetShareSettings.includeMasterData), boolValue(sheet.share?.includePicture ?? defaultLogSheetShareSettings.includePicture), boolValue(sheet.share?.includeLogLines ?? defaultLogSheetShareSettings.includeLogLines), boolValue(sheet.share?.includeTechnicalLog ?? defaultLogSheetShareSettings.includeTechnicalLog), boolValue(sheet.share?.includeSkipper ?? defaultLogSheetShareSettings.includeSkipper), boolValue(sheet.share?.includeCrew ?? defaultLogSheetShareSettings.includeCrew)], + [scopedId(ownerId, sheet.id), sheet.title, sheet.dateRange, sheet.status, sheet.source ?? null, sheet.verificationNote ?? null, sheet.scannerWarnings ? JSON.stringify(sheet.scannerWarnings) : null, scopedId(ownerId, sheet.boatId), JSON.stringify({}), JSON.stringify(sheet.route), JSON.stringify({}), JSON.stringify({}), JSON.stringify([]), JSON.stringify(sheet.watchPlan), JSON.stringify(sheet.technicalChecks), ...imageValues(sheet.image), ownerId, overallPrivacy(sheet.share), privacyFor(sheet.share?.masterData), privacyFor(sheet.share?.picture), privacyFor(sheet.share?.logLines), privacyFor(sheet.share?.technicalLog), privacyFor(sheet.share?.skipper), privacyFor(sheet.share?.crew)], ); } @@ -112,21 +112,29 @@ function mapStoredSheet(sheet: LogSheetRow): StoredLogSheet { technicalChecks: parseJson(sheet.technical_checks), ...(imageFromRow(sheet) ? { image: imageFromRow(sheet) } : {}), share: { - privacy: sheet.share_privacy ?? defaultLogSheetShareSettings.privacy, - includeMasterData: boolFromRow(sheet.share_master_data, defaultLogSheetShareSettings.includeMasterData), - includePicture: boolFromRow(sheet.share_picture, defaultLogSheetShareSettings.includePicture), - includeLogLines: boolFromRow(sheet.share_loglines, defaultLogSheetShareSettings.includeLogLines), - includeTechnicalLog: boolFromRow(sheet.share_technical_log, defaultLogSheetShareSettings.includeTechnicalLog), - includeSkipper: boolFromRow(sheet.share_skipper, defaultLogSheetShareSettings.includeSkipper), - includeCrew: boolFromRow(sheet.share_crew, defaultLogSheetShareSettings.includeCrew), + masterData: privacyFromRow(sheet.share_master_data, sheet.share_privacy), + picture: privacyFromRow(sheet.share_picture, sheet.share_privacy), + logLines: privacyFromRow(sheet.share_loglines, sheet.share_privacy), + technicalLog: privacyFromRow(sheet.share_technical_log, sheet.share_privacy), + skipper: privacyFromRow(sheet.share_skipper, sheet.share_privacy), + crew: privacyFromRow(sheet.share_crew, sheet.share_privacy), }, }; } -function boolValue(value: boolean) { - return value ? 1 : 0; +function privacyFor(value: NonNullable[keyof NonNullable] | undefined) { + return value ?? "private"; } -function boolFromRow(value: number | null | undefined, fallback: boolean) { - return value == null ? fallback : Boolean(value); +function overallPrivacy(share: LogSheet["share"]) { + const settings = share ?? defaultLogSheetShareSettings; + if (Object.values(settings).includes("public")) return "public"; + if (Object.values(settings).includes("registered")) return "registered"; + return "private"; +} + +function privacyFromRow(value: unknown, legacyPrivacy: NonNullable[keyof NonNullable] | null | undefined) { + if (value === "public" || value === "registered" || value === "private") return value; + if (value === 1 || value === true) return legacyPrivacy === "registered" ? "registered" : "public"; + return "private"; } diff --git a/app/models/log-sheet.ts b/app/models/log-sheet.ts index 7dd3300..a97d77a 100644 --- a/app/models/log-sheet.ts +++ b/app/models/log-sheet.ts @@ -5,23 +5,21 @@ import type { LogLine } from "./log-line"; export type LogSheetSharePrivacy = "private" | "registered" | "public"; export type LogSheetShareSettings = { - privacy: LogSheetSharePrivacy; - includeMasterData: boolean; - includePicture: boolean; - includeLogLines: boolean; - includeTechnicalLog: boolean; - includeSkipper: boolean; - includeCrew: boolean; + masterData: LogSheetSharePrivacy; + picture: LogSheetSharePrivacy; + logLines: LogSheetSharePrivacy; + technicalLog: LogSheetSharePrivacy; + skipper: LogSheetSharePrivacy; + crew: LogSheetSharePrivacy; }; export const defaultLogSheetShareSettings: LogSheetShareSettings = { - privacy: "private", - includeMasterData: true, - includePicture: true, - includeLogLines: true, - includeTechnicalLog: true, - includeSkipper: true, - includeCrew: true, + masterData: "private", + picture: "private", + logLines: "private", + technicalLog: "private", + skipper: "private", + crew: "private", }; export type LogSheet = { diff --git a/app/models/logbook-rows.ts b/app/models/logbook-rows.ts index 35149a0..968a324 100644 --- a/app/models/logbook-rows.ts +++ b/app/models/logbook-rows.ts @@ -33,12 +33,12 @@ export type LogSheetRow = ImageRowFields & { watch_plan: unknown; technical_checks: unknown; share_privacy?: LogSheetSharePrivacy | null; - share_master_data?: number | null; - share_picture?: number | null; - share_loglines?: number | null; - share_technical_log?: number | null; - share_skipper?: number | null; - share_crew?: number | null; + share_master_data?: LogSheetSharePrivacy | number | boolean | null; + share_picture?: LogSheetSharePrivacy | number | boolean | null; + share_loglines?: LogSheetSharePrivacy | number | boolean | null; + share_technical_log?: LogSheetSharePrivacy | number | boolean | null; + share_skipper?: LogSheetSharePrivacy | number | boolean | null; + share_crew?: LogSheetSharePrivacy | number | boolean | null; owner_id?: string; }; diff --git a/tests/app/lib/repositories/log-sheets-repository.test.ts b/tests/app/lib/repositories/log-sheets-repository.test.ts index 63bc9aa..601ad9d 100644 --- a/tests/app/lib/repositories/log-sheets-repository.test.ts +++ b/tests/app/lib/repositories/log-sheets-repository.test.ts @@ -50,7 +50,7 @@ describe("LogSheetsRepository", () => { await new LogSheetsRepository(db).insert(sheet); expect(db.calls[0].sql).toContain("insert into log_sheets"); - expect(db.calls[0].values).toEqual([`legacy-user:${sheet.id}`, sheet.title, sheet.dateRange, sheet.status, null, null, null, `legacy-user:${sheet.boatId}`, JSON.stringify({}), JSON.stringify(sheet.route), JSON.stringify({}), JSON.stringify({}), JSON.stringify([]), JSON.stringify(sheet.watchPlan), JSON.stringify(sheet.technicalChecks), null, null, null, null, "legacy-user", "private", 1, 1, 1, 1, 1, 1]); + expect(db.calls[0].values).toEqual([`legacy-user:${sheet.id}`, sheet.title, sheet.dateRange, sheet.status, null, null, null, `legacy-user:${sheet.boatId}`, JSON.stringify({}), JSON.stringify(sheet.route), JSON.stringify({}), JSON.stringify({}), JSON.stringify([]), JSON.stringify(sheet.watchPlan), JSON.stringify(sheet.technicalChecks), null, null, null, null, "legacy-user", "private", "private", "private", "private", "private", "private", "private"]); }); it("maps relational rows back to a persisted logbook", () => { @@ -62,7 +62,7 @@ describe("LogSheetsRepository", () => { expect(LogSheetsRepository.toLogbook([boatRow], [sheetRow], [crewRow], [lineRow])).toEqual({ boats: [boat], crewMembers: [], - sheets: [{ ...sheet, share: { privacy: "private", includeMasterData: true, includePicture: true, includeLogLines: true, includeTechnicalLog: true, includeSkipper: true, includeCrew: true }, crew: [{ ...crew, isPrimary: false }], lines: [line] }], + sheets: [{ ...sheet, share: { masterData: "private", picture: "private", logLines: "private", technicalLog: "private", skipper: "private", crew: "private" }, crew: [{ ...crew, isPrimary: false }], lines: [line] }], }); }); From f3f3eed9632feb27f8b0ad5c150998e9a90d111c Mon Sep 17 00:00:00 2001 From: Julian V Date: Thu, 16 Jul 2026 21:31:13 +0200 Subject: [PATCH 05/10] Fix share privacy persistence compatibility --- app/lib/db/migrations/020_log_sheet_sharing.sql | 12 ++++++------ app/lib/db/schema.sql | 12 ++++++------ app/lib/repositories/log-sheets-repository.ts | 7 +++++-- .../lib/repositories/log-sheets-repository.test.ts | 2 +- 4 files changed, 18 insertions(+), 15 deletions(-) diff --git a/app/lib/db/migrations/020_log_sheet_sharing.sql b/app/lib/db/migrations/020_log_sheet_sharing.sql index ea9a7bd..cf69320 100644 --- a/app/lib/db/migrations/020_log_sheet_sharing.sql +++ b/app/lib/db/migrations/020_log_sheet_sharing.sql @@ -1,9 +1,9 @@ alter table log_sheets add column share_privacy text not null default 'private'; -alter table log_sheets add column share_master_data text not null default 'private'; -alter table log_sheets add column share_picture text not null default 'private'; -alter table log_sheets add column share_loglines text not null default 'private'; -alter table log_sheets add column share_technical_log text not null default 'private'; -alter table log_sheets add column share_skipper text not null default 'private'; -alter table log_sheets add column share_crew text not null default 'private'; +alter table log_sheets add column share_master_data integer not null default 0; +alter table log_sheets add column share_picture integer not null default 0; +alter table log_sheets add column share_loglines integer not null default 0; +alter table log_sheets add column share_technical_log integer not null default 0; +alter table log_sheets add column share_skipper integer not null default 0; +alter table log_sheets add column share_crew integer not null default 0; create index if not exists log_sheets_share_privacy_idx on log_sheets (share_privacy); diff --git a/app/lib/db/schema.sql b/app/lib/db/schema.sql index b660f7a..7967771 100644 --- a/app/lib/db/schema.sql +++ b/app/lib/db/schema.sql @@ -95,12 +95,12 @@ create table if not exists log_sheets ( image_width integer, image_height integer, share_privacy text not null default 'private', - share_master_data text not null default 'private', - share_picture text not null default 'private', - share_loglines text not null default 'private', - share_technical_log text not null default 'private', - share_skipper text not null default 'private', - share_crew text not null default 'private' + share_master_data integer not null default 0, + share_picture integer not null default 0, + share_loglines integer not null default 0, + share_technical_log integer not null default 0, + share_skipper integer not null default 0, + share_crew integer not null default 0 ); create index if not exists log_sheets_share_privacy_idx on log_sheets (share_privacy); diff --git a/app/lib/repositories/log-sheets-repository.ts b/app/lib/repositories/log-sheets-repository.ts index 066e14f..9afa296 100644 --- a/app/lib/repositories/log-sheets-repository.ts +++ b/app/lib/repositories/log-sheets-repository.ts @@ -123,7 +123,9 @@ function mapStoredSheet(sheet: LogSheetRow): StoredLogSheet { } function privacyFor(value: NonNullable[keyof NonNullable] | undefined) { - return value ?? "private"; + if (value === "public") return 1; + if (value === "registered") return 2; + return 0; } function overallPrivacy(share: LogSheet["share"]) { @@ -135,6 +137,7 @@ function overallPrivacy(share: LogSheet["share"]) { function privacyFromRow(value: unknown, legacyPrivacy: NonNullable[keyof NonNullable] | null | undefined) { if (value === "public" || value === "registered" || value === "private") return value; - if (value === 1 || value === true) return legacyPrivacy === "registered" ? "registered" : "public"; + if (value === 2 || value === "2") return "registered"; + if (value === 1 || value === "1" || value === true) return legacyPrivacy === "registered" ? "registered" : "public"; return "private"; } diff --git a/tests/app/lib/repositories/log-sheets-repository.test.ts b/tests/app/lib/repositories/log-sheets-repository.test.ts index 601ad9d..5ab9b62 100644 --- a/tests/app/lib/repositories/log-sheets-repository.test.ts +++ b/tests/app/lib/repositories/log-sheets-repository.test.ts @@ -50,7 +50,7 @@ describe("LogSheetsRepository", () => { await new LogSheetsRepository(db).insert(sheet); expect(db.calls[0].sql).toContain("insert into log_sheets"); - expect(db.calls[0].values).toEqual([`legacy-user:${sheet.id}`, sheet.title, sheet.dateRange, sheet.status, null, null, null, `legacy-user:${sheet.boatId}`, JSON.stringify({}), JSON.stringify(sheet.route), JSON.stringify({}), JSON.stringify({}), JSON.stringify([]), JSON.stringify(sheet.watchPlan), JSON.stringify(sheet.technicalChecks), null, null, null, null, "legacy-user", "private", "private", "private", "private", "private", "private", "private"]); + expect(db.calls[0].values).toEqual([`legacy-user:${sheet.id}`, sheet.title, sheet.dateRange, sheet.status, null, null, null, `legacy-user:${sheet.boatId}`, JSON.stringify({}), JSON.stringify(sheet.route), JSON.stringify({}), JSON.stringify({}), JSON.stringify([]), JSON.stringify(sheet.watchPlan), JSON.stringify(sheet.technicalChecks), null, null, null, null, "legacy-user", "private", 0, 0, 0, 0, 0, 0]); }); it("maps relational rows back to a persisted logbook", () => { From 9a02451a525e91ee21b48d7a33e8119a886f1bdb Mon Sep 17 00:00:00 2001 From: Julian V Date: Sat, 18 Jul 2026 18:24:51 +0200 Subject: [PATCH 06/10] Improve mobile share privacy dialog --- .../logbook/pages/LogbookDetailsPage.tsx | 32 +++++--- app/globals.css | 79 +++++++++++++++++++ 2 files changed, 98 insertions(+), 13 deletions(-) diff --git a/app/components/logbook/pages/LogbookDetailsPage.tsx b/app/components/logbook/pages/LogbookDetailsPage.tsx index 20ceedb..69d9f67 100644 --- a/app/components/logbook/pages/LogbookDetailsPage.tsx +++ b/app/components/logbook/pages/LogbookDetailsPage.tsx @@ -662,24 +662,30 @@ export function LogbookDetailsPage(props: LogbookDetailsPageProps) { {isShareDialogOpen && ( -
-
-
+
+
+

Share logsheet

- {isSharingEnabled ? ( -

Share URL: {shareUrl}

- ) : ( -

Set at least one part to public or registered users to enable this share link.

- )} -
+
+ {isSharingEnabled ? ( + <> + Share URL + {shareUrl} + + ) : ( +

Set at least one part to public or registered users to enable this share link.

+ )} +
+
+ Privacy by logsheet part {shareOptions.map(([field, label]) => ( -
+
)} diff --git a/app/globals.css b/app/globals.css index 7f6d192..043d9e1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1226,3 +1226,82 @@ img.entity-image--preview { .pagination-buttons button { min-height: auto; border: 1px solid var(--border); border-radius: 0.65rem; padding: 0.4rem 0.65rem; background: white; color: var(--foreground); font-weight: 800; } .pagination-buttons button.active { border-color: var(--primary); background: rgba(15, 107, 143, 0.12); color: var(--primary); } .pagination-buttons button:disabled { cursor: not-allowed; opacity: 0.45; } + +.share-logsheet-modal { + position: fixed; + inset: 0; + z-index: 70; + display: grid; + place-items: center; + padding: 1rem; + overflow: auto; + background: rgba(12, 24, 38, 0.72); +} + +.share-logsheet-panel { + display: grid; + gap: 1.1rem; + width: min(100%, 38rem); + max-height: calc(100dvh - 2rem); + border-radius: 1.5rem; + padding: clamp(1rem, 2vw, 1.5rem); + overflow: auto; + background: var(--card); + box-shadow: var(--shadow); +} + +.share-logsheet-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + +.share-logsheet-heading h2, +.share-logsheet-url p { + margin: 0; +} + +.share-logsheet-url { + display: grid; + gap: 0.35rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); + overflow-wrap: anywhere; +} + +.share-logsheet-url span, +.share-logsheet-options legend { + font-weight: 900; +} + +.share-logsheet-options { + display: grid; + gap: 0.9rem; + min-width: 0; + margin: 0; + padding: 0; + border: 0; +} + +.share-logsheet-options legend { + padding-bottom: 0.25rem; +} + +.share-logsheet-option { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 0.35rem; + padding-bottom: 0.85rem; + border-bottom: 1px solid var(--border); +} + +.share-logsheet-option:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.share-logsheet-option select { + width: 100%; + min-height: 2.75rem; +} From 7a37f31c68097cd78e5073a1c70a909011ecb22a Mon Sep 17 00:00:00 2001 From: Julian V Date: Sat, 18 Jul 2026 18:37:23 +0200 Subject: [PATCH 07/10] Keep share privacy selections responsive --- app/components/logbook/pages/LogbookDetailsPage.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/components/logbook/pages/LogbookDetailsPage.tsx b/app/components/logbook/pages/LogbookDetailsPage.tsx index 69d9f67..d81e606 100644 --- a/app/components/logbook/pages/LogbookDetailsPage.tsx +++ b/app/components/logbook/pages/LogbookDetailsPage.tsx @@ -109,6 +109,7 @@ export function LogbookDetailsPage(props: LogbookDetailsPageProps) { const onShowCourseColumnsChange = props.onShowCourseColumnsChange as (show: boolean) => void; const [isMapExpanded, setIsMapExpanded] = useState(false); const [isShareDialogOpen, setIsShareDialogOpen] = useState(false); + const [shareDraftState, setShareDraftState] = useState<{ sheetId: string; share: LogSheetShareSettings }>({ sheetId: "", share: defaultLogSheetShareSettings }); const [newTechnicalCheck, setNewTechnicalCheck] = useState(""); const [technicalCheckDraftState, setTechnicalCheckDraftState] = useState<{ sheetId: string; drafts: Record }>({ sheetId: "", drafts: {} }); const [openCourseTooltip, setOpenCourseTooltip] = useState(null); @@ -116,12 +117,15 @@ export function LogbookDetailsPage(props: LogbookDetailsPageProps) { const technicalCheckDrafts = technicalCheckDraftState.sheetId === activeSheet.id ? technicalCheckDraftState.drafts : {}; const scannerWarnings = activeSheet.scannerWarnings ?? []; const share = activeSheet.share ?? defaultLogSheetShareSettings; + const shareDraft = shareDraftState.sheetId === activeSheet.id ? shareDraftState.share : share; const sharePath = sharingOwnerId ? `/share/${encodeURIComponent(sharingOwnerId)}/${encodeURIComponent(activeSheet.id)}` : `/share/${encodeURIComponent(activeSheet.id)}`; const shareUrl = typeof window === "undefined" ? sharePath : `${window.location.origin}${sharePath}`; const setShare = (patch: Partial) => { - void updateShare({ ...share, ...patch }); + const nextShare = { ...shareDraft, ...patch }; + setShareDraftState({ sheetId: activeSheet.id, share: nextShare }); + void updateShare(nextShare); }; - const isSharingEnabled = Object.values(share).some((privacy) => privacy !== "private"); + const isSharingEnabled = Object.values(shareDraft).some((privacy) => privacy !== "private"); const shareOptions = [ ["masterData", "Master data (from/to/boat)"], ["picture", "Picture"], @@ -684,7 +688,7 @@ export function LogbookDetailsPage(props: LogbookDetailsPageProps) {