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/components/LogbookApp.tsx b/app/components/LogbookApp.tsx index 6e3c61c..d1b62ea 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"; @@ -44,6 +45,7 @@ import { } from "./logbook/date-utils"; import { ManagerShell } from "./managers/ManagerShell"; import { courseConversionColumns } from "../domain/nautical/course-conversion"; +import { calculateLogSheetMetrics, formatLogSheetDuration } from "../domain/logbook/sheet-metrics"; import { lineFormToLogLine } from "../domain/log-lines/log-line-form"; import { ModuleTabs, type ActiveView } from "../templates/ModuleTabs"; import { useI18n } from "../lib/i18n"; @@ -153,55 +155,17 @@ const mockSocialUsers: SocialUser[] = [ }, ]; -function parseLogTimeMinutes(time: string) { - const match = time.match(/^(\d{1,2}):(\d{2})/); - if (!match) return undefined; - const hours = Number.parseInt(match[1], 10); - const minutes = Number.parseInt(match[2], 10); - if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return undefined; - return hours * 60 + minutes; -} - -function formatDuration(minutes: number) { - const safeMinutes = Math.max(0, Math.round(minutes)); - const hours = Math.floor(safeMinutes / 60); - const remainingMinutes = safeMinutes % 60; - return `${hours}h ${remainingMinutes.toString().padStart(2, "0")}m`; -} - -function logLineDistanceDeltas(lines: LogLine[]) { - return lines.map((line, index) => - Math.max(0, line.logNm - (lines[index - 1]?.logNm ?? 0)), - ); -} - function calculateSheetSummary(sheet: LogSheet) { - const deltas = logLineDistanceDeltas(sheet.lines); - const motorMiles = deltas.reduce( - (sum, delta, index) => - sum + ((sheet.lines[index]?.motorHours ?? 0) > 0 || (sheet.lines[index]?.motorMiles ?? 0) > 0 ? delta : 0), - 0, - ); - const totalMiles = deltas.reduce((sum, delta) => sum + delta, 0); - const sailMiles = Math.max(0, totalMiles - motorMiles); - const firstTime = parseLogTimeMinutes(sheet.lines[0]?.time ?? ""); - const lastTime = parseLogTimeMinutes(sheet.lines.at(-1)?.time ?? ""); - const durationMinutes = - firstTime === undefined || lastTime === undefined - ? undefined - : lastTime >= firstTime - ? lastTime - firstTime - : lastTime + 24 * 60 - firstTime; - + const metrics = sheet.metrics ?? calculateLogSheetMetrics(sheet.lines); return { - motorMiles, - sailMiles, - totalMiles, - duration: - durationMinutes === undefined ? "—" : formatDuration(durationMinutes), + motorMiles: metrics.motorMiles, + sailMiles: metrics.sailMiles, + totalMiles: metrics.totalMiles, + duration: formatLogSheetDuration(metrics.durationMinutes), }; } + export function LogbookApp({ userId, userEmail, @@ -1032,6 +996,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 = { @@ -1694,9 +1668,11 @@ export function LogbookApp({ navigate={navigate} cancelSheetEdit={cancelSheetEdit} activeSheet={activeSheet} + userId={userId} 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..5f1e674 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,8 @@ 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; const logbook = props.logbook as PersistedLogbook; @@ -103,12 +108,33 @@ 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 [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); 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 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) => { + const nextShare = { ...shareDraft, ...patch }; + setShareDraftState({ sheetId: activeSheet.id, share: nextShare }); + void updateShare(nextShare); + }; + const isSharingEnabled = Object.values(shareDraft).some((privacy) => privacy !== "private"); + const shareOptions = [ + ["masterData", "Master data (from/to/boat)"], + ["picture", "Picture"], + ["logLines", "Loglines"], + ["metrics", "Metrics (time and miles)"], + ["technicalLog", "Technical log"], + ["skipper", "Skipper"], + ["crew", "Crew information"], + ] as const; const showScannerDraftNotice = activeSheet.source === "scanner" && activeSheet.status === "Draft"; const courseConversionSequence = useRef(0); @@ -537,6 +563,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.

+ )} +
+
+ Privacy by logsheet part + {shareOptions.map(([field, label]) => ( + + ))} +
+ + + )} +
Math.max(0, line.logNm - (lines[index - 1]?.logNm ?? 0))); + const motorMiles = deltas.reduce((sum, delta, index) => sum + ((lines[index]?.motorHours ?? 0) > 0 || (lines[index]?.motorMiles ?? 0) > 0 ? delta : 0), 0); + const totalMiles = deltas.reduce((sum, delta) => sum + delta, 0); + const sailMiles = Math.max(0, totalMiles - motorMiles); + const firstTime = parseLogTimeMinutes(lines[0]?.time ?? ""); + const lastTime = parseLogTimeMinutes(lines.at(-1)?.time ?? ""); + const durationMinutes = firstTime === undefined || lastTime === undefined ? null : lastTime >= firstTime ? lastTime - firstTime : lastTime + 24 * 60 - firstTime; + return { motorMiles, sailMiles, totalMiles, durationMinutes }; +} + +export function formatLogSheetDuration(durationMinutes: number | null | undefined) { + if (durationMinutes == null) return "—"; + const safeMinutes = Math.max(0, Math.round(durationMinutes)); + const hours = Math.floor(safeMinutes / 60); + const remainingMinutes = safeMinutes % 60; + return `${hours}h ${remainingMinutes.toString().padStart(2, "0")}m`; +} + +function parseLogTimeMinutes(time: string) { + const match = time.match(/^(\d{1,2}):(\d{2})/); + if (!match) return undefined; + const hours = Number.parseInt(match[1], 10); + const minutes = Number.parseInt(match[2], 10); + if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return undefined; + return hours * 60 + minutes; +} 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; +} diff --git a/app/lib/db/logbook-database.ts b/app/lib/db/logbook-database.ts index 1315b75..aab873f 100644 --- a/app/lib/db/logbook-database.ts +++ b/app/lib/db/logbook-database.ts @@ -1,7 +1,8 @@ -import type { PersistedLogbook } from "../../models/logbook"; +import { defaultLogSheetShareSettings, type LogSheet, type PersistedLogbook } from "../../models/logbook"; 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,6 +54,29 @@ export abstract class LogbookDatabase implements QueryableDatabase { return logbook; } + async readSharedSheet(sheetId: string, isAuthenticated: boolean, ownerId?: string): Promise<{ sheet: LogSheet; boatName: string } | undefined> { + await this.ensureSchemaAndBackfill(); + const sharedRow = ownerId + ? await this.sheets.findSharedByScopedId(scopedId(ownerId, sheetId)) + : await this.sheets.findSharedByUnscopedId(sheetId); + if (!sharedRow?.owner_id) return undefined; + + const share = LogSheetsRepository.toLogbook([], [sharedRow], [], []).sheets[0]?.share ?? defaultLogSheetShareSettings; + const visibility = sectionVisibility(share, isAuthenticated); + if (!Object.values(visibility).some(Boolean)) return undefined; + + const [boatRow, crewRows, lineRows] = await Promise.all([ + visibility.masterData ? this.boats.findByScopedId(sharedRow.boat_id) : undefined, + (visibility.skipper || visibility.crew) ? this.crew.findForSheet(sharedRow.id, sharedRow.owner_id) : [], + visibility.logLines ? 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, visibility), boatName: visibility.masterData ? boat?.name ?? "" : "" }; + } + protected async readTables(): Promise { const [boats, sheets, crewProfiles, crew, lines] = await Promise.all([ this.boats.findAll(this.ownerId), @@ -77,3 +101,39 @@ export abstract class LogbookDatabase implements QueryableDatabase { await this.crew.ensurePrimaryProfile(this.ownerId); } } + +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), + metrics: canViewSection(share.metrics, 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: visibility.masterData ? sheet.boatId : "", + route: visibility.masterData ? sheet.route : { from: "", to: "", departed: "", arrived: "" }, + image: visibility.picture ? sheet.image : undefined, + lines: visibility.logLines ? sheet.lines : [], + metrics: visibility.metrics ? sheet.metrics : undefined, + 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 new file mode 100644 index 0000000..689151c --- /dev/null +++ b/app/lib/db/migrations/020_log_sheet_sharing.sql @@ -0,0 +1,16 @@ +alter table log_sheets add column motor_miles real not null default 0; +alter table log_sheets add column sail_miles real not null default 0; +alter table log_sheets add column total_miles real not null default 0; +alter table log_sheets add column duration_minutes integer; +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 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_metrics 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); +create index if not exists log_sheets_total_miles_idx on log_sheets (total_miles); +create index if not exists log_sheets_duration_minutes_idx on log_sheets (duration_minutes); diff --git a/app/lib/db/schema.sql b/app/lib/db/schema.sql index 308ba73..2e8b1be 100644 --- a/app/lib/db/schema.sql +++ b/app/lib/db/schema.sql @@ -93,9 +93,25 @@ create table if not exists log_sheets ( image_data text, image_mime_type text, image_width integer, - image_height integer + image_height integer, + motor_miles real not null default 0, + sail_miles real not null default 0, + total_miles real not null default 0, + duration_minutes integer, + share_privacy 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_metrics 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); +create index if not exists log_sheets_total_miles_idx on log_sheets (total_miles); +create index if not exists log_sheets_duration_minutes_idx on log_sheets (duration_minutes); + 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 d3b34a1..af62c93 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, 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 8771d54..22e1feb 100644 --- a/app/lib/repositories/log-sheets-repository.ts +++ b/app/lib/repositories/log-sheets-repository.ts @@ -1,4 +1,5 @@ -import { normalizeDeviationTable, type Boat, type BoatRow, type CrewMemberRow, type LogLineRow, type LogSheet, type LogSheetRow, type PersistedLogbook, type StoredLogSheet } from "../../models/logbook"; +import { calculateLogSheetMetrics } from "../../domain/logbook/sheet-metrics"; +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 +10,23 @@ 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]; + } + 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") { + const metrics = calculateLogSheetMetrics(sheet.lines); 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, motor_miles, sail_miles, total_miles, duration_minutes, share_privacy, share_master_data, share_picture, share_loglines, share_metrics, share_technical_log, share_skipper, share_crew) values (${this.values(32)})`, + [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, metrics.motorMiles, metrics.sailMiles, metrics.totalMiles, metrics.durationMinutes, overallPrivacy(sheet.share), privacyFor(sheet.share?.masterData), privacyFor(sheet.share?.picture), privacyFor(sheet.share?.logLines), privacyFor(sheet.share?.metrics), privacyFor(sheet.share?.technicalLog), privacyFor(sheet.share?.skipper), privacyFor(sheet.share?.crew)], ); } @@ -103,5 +113,40 @@ function mapStoredSheet(sheet: LogSheetRow): StoredLogSheet { watchPlan: parseJson(sheet.watch_plan), technicalChecks: parseJson(sheet.technical_checks), ...(imageFromRow(sheet) ? { image: imageFromRow(sheet) } : {}), + metrics: { + motorMiles: Number(sheet.motor_miles) || 0, + sailMiles: Number(sheet.sail_miles) || 0, + totalMiles: Number(sheet.total_miles) || 0, + durationMinutes: sheet.duration_minutes == null ? null : Number(sheet.duration_minutes), + }, + share: { + 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), + metrics: privacyFromRow(sheet.share_metrics, 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 privacyFor(value: NonNullable[keyof NonNullable] | undefined) { + if (value === "public") return 1; + if (value === "registered") return 2; + return 0; +} + +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 === 2 || value === "2") return "registered"; + if (value === 1 || 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 ad26fd2..2653532 100644 --- a/app/models/log-sheet.ts +++ b/app/models/log-sheet.ts @@ -1,6 +1,29 @@ import type { SheetCrewMember } from "./crew-member"; import type { StoredImage } from "./stored-image"; import type { LogLine } from "./log-line"; +import type { LogSheetMetrics } from "../domain/logbook/sheet-metrics"; + +export type LogSheetSharePrivacy = "private" | "registered" | "public"; + +export type LogSheetShareSettings = { + masterData: LogSheetSharePrivacy; + picture: LogSheetSharePrivacy; + logLines: LogSheetSharePrivacy; + metrics: LogSheetSharePrivacy; + technicalLog: LogSheetSharePrivacy; + skipper: LogSheetSharePrivacy; + crew: LogSheetSharePrivacy; +}; + +export const defaultLogSheetShareSettings: LogSheetShareSettings = { + masterData: "private", + picture: "private", + logLines: "private", + metrics: "private", + technicalLog: "private", + skipper: "private", + crew: "private", +}; export type LogSheet = { id: string; @@ -22,4 +45,6 @@ export type LogSheet = { technicalChecks: string[]; image?: StoredImage; lines: LogLine[]; + metrics?: LogSheetMetrics; + share?: LogSheetShareSettings; }; diff --git a/app/models/logbook-rows.ts b/app/models/logbook-rows.ts index 5133823..c9d19fd 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,19 @@ export type LogSheetRow = ImageRowFields & { remarks: unknown; watch_plan: unknown; technical_checks: unknown; + motor_miles?: number | null; + sail_miles?: number | null; + total_miles?: number | null; + duration_minutes?: number | null; + share_privacy?: LogSheetSharePrivacy | null; + share_master_data?: LogSheetSharePrivacy | number | boolean | null; + share_picture?: LogSheetSharePrivacy | number | boolean | null; + share_loglines?: LogSheetSharePrivacy | number | boolean | null; + share_metrics?: 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; }; 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/[[...segments]]/page.tsx b/app/share/[[...segments]]/page.tsx new file mode 100644 index 0000000..6a8238c --- /dev/null +++ b/app/share/[[...segments]]/page.tsx @@ -0,0 +1,117 @@ +import { auth } from "../../../auth"; +import { readSharedLogSheet } from "../../lib/logbook-store"; +import { EntityImage } from "../../components/logbook/EntityImage"; +import { LogLinesMapView } from "../../components/logbook/OpenSeaMapView"; +import { formatLogSheetDuration } from "../../domain/logbook/sheet-metrics"; + +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 = sheetId ? await readSharedLogSheet(sheetId, Boolean(session?.user?.id), ownerId) : undefined; + + 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 metrics = sheet.metrics; + const hasCrew = sheet.crew.length > 0; + const hasTechnicalLog = sheet.technicalChecks.length > 0; + const hasLogLines = sheet.lines.length > 0; + const hasMetrics = Boolean(metrics); + const hasSupportContent = hasCrew || hasTechnicalLog || hasLogLines; + + 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} +
+ )} +
+ + {hasMetrics ? ( +
+
Motor miles{metrics?.motorMiles ?? 0} nm
+
Sail miles{metrics?.sailMiles ?? 0} nm
+
Total miles{metrics?.totalMiles ?? 0} nm
+
Duration{formatLogSheetDuration(metrics?.durationMinutes)}
+
+ ) : null} + + {hasLogLines ? ( +
+

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} + + {hasSupportContent ? ( +
+ {hasCrew ? ( +
+

Crew

+
    + {sheet.crew.map((person, index) => ( +
  • +
    + {index + 1}. {index === 0 ? "⭐ Skipper · " : ""}{person.name} + {[person.nationality, person.role].filter(Boolean).join(" · ")} +
    +
  • + ))} +
+
+ ) : null} + + {hasTechnicalLog ? ( +
+

Technical log

+
    {sheet.technicalChecks.map((item, index) =>
  • {item}
  • )}
+
+ ) : null} + + {hasLogLines ? ( +
+

Positions

+ +
+ ) : null} +
+ ) : null} +
+
+ ); +} + +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/proxy.ts b/proxy.ts index 3299fdf..68477e6 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,11 +1,17 @@ import { NextRequest, NextResponse } from "next/server"; export function proxy(request: NextRequest) { + if (isPublicShareRequest(request.nextUrl.pathname)) return NextResponse.next(); + const isLoggedIn = request.cookies.has("authjs.session-token") || request.cookies.has("__Secure-authjs.session-token"); if (isLoggedIn) return NextResponse.next(); return NextResponse.redirect(new URL("/login", request.url)); } +function isPublicShareRequest(pathname: string) { + return pathname === "/share" || pathname.startsWith("/share/") || pathname.startsWith("/api/shared/logbooks"); +} + export const config = { matcher: ["/((?!api/auth|api/register|api/demo-login|api/password-reset|login|register|forgot-password|reset-password|_next/static|_next/image|favicon.ico).*)"], }; diff --git a/tests/app/lib/repositories/log-sheets-repository.test.ts b/tests/app/lib/repositories/log-sheets-repository.test.ts index 518041b..53bc6a9 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", 9, 54, 63, null, "private", 0, 0, 0, 0, 0, 0, 0]); }); 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, metrics: { motorMiles: 0, sailMiles: 0, totalMiles: 0, durationMinutes: null }, share: { masterData: "private", picture: "private", logLines: "private", metrics: "private", technicalLog: "private", skipper: "private", crew: "private" }, crew: [{ ...crew, isPrimary: false }], lines: [line] }], }); });