Extract coordinates from parsed GPX tracks and store as PostGIS LineString geometry via ST_GeomFromGeoJSON. Applied to: - createRoute / updateRoute (routes.server.ts) - createActivity / createRouteFromActivity (activities.server.ts) Adds lineStringFromCoords() helper to the journal schema that builds the SQL expression from [lon, lat] coordinate pairs. Unblocks spatial queries (e.g. route discovery via ST_Intersects). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
124 lines
4 KiB
TypeScript
124 lines
4 KiB
TypeScript
import {
|
|
pgSchema,
|
|
text,
|
|
timestamp,
|
|
integer,
|
|
real,
|
|
jsonb,
|
|
customType,
|
|
} from "drizzle-orm/pg-core";
|
|
import { sql, type SQL } from "drizzle-orm";
|
|
|
|
const bytea = customType<{ data: Buffer }>({
|
|
dataType() {
|
|
return "bytea";
|
|
},
|
|
});
|
|
|
|
const lineString = customType<{ data: string | SQL }>({
|
|
dataType() {
|
|
return "geometry(LineString, 4326)";
|
|
},
|
|
toDriver(value) {
|
|
return value;
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Build a SQL expression to create a PostGIS LineString from coordinate pairs.
|
|
* Coordinates are [lon, lat] (GeoJSON order).
|
|
*/
|
|
export function lineStringFromCoords(coords: [number, number][]): SQL {
|
|
const geojson = JSON.stringify({
|
|
type: "LineString",
|
|
coordinates: coords,
|
|
});
|
|
return sql`ST_GeomFromGeoJSON(${geojson})`;
|
|
}
|
|
|
|
export const journalSchema = pgSchema("journal");
|
|
|
|
export const users = journalSchema.table("users", {
|
|
id: text("id").primaryKey(),
|
|
email: text("email").notNull().unique(),
|
|
username: text("username").notNull().unique(),
|
|
displayName: text("display_name"),
|
|
bio: text("bio"),
|
|
domain: text("domain").notNull(),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export const credentials = journalSchema.table("credentials", {
|
|
id: text("id").primaryKey(),
|
|
userId: text("user_id")
|
|
.notNull()
|
|
.references(() => users.id, { onDelete: "cascade" }),
|
|
credentialId: bytea("credential_id").notNull(),
|
|
publicKey: bytea("public_key").notNull(),
|
|
counter: integer("counter").notNull().default(0),
|
|
deviceType: text("device_type"),
|
|
transports: jsonb("transports").$type<string[]>(),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export const magicTokens = journalSchema.table("magic_tokens", {
|
|
id: text("id").primaryKey(),
|
|
email: text("email").notNull(),
|
|
token: text("token").notNull().unique(),
|
|
purpose: text("purpose").notNull().default("login"),
|
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
usedAt: timestamp("used_at", { withTimezone: true }),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export const routes = journalSchema.table("routes", {
|
|
id: text("id").primaryKey(),
|
|
ownerId: text("owner_id")
|
|
.notNull()
|
|
.references(() => users.id),
|
|
name: text("name").notNull(),
|
|
description: text("description").default(""),
|
|
gpx: text("gpx"),
|
|
geom: lineString("geom"),
|
|
routingProfile: text("routing_profile"),
|
|
distance: real("distance"),
|
|
elevationGain: real("elevation_gain"),
|
|
elevationLoss: real("elevation_loss"),
|
|
dayBreaks: jsonb("day_breaks").$type<number[]>(),
|
|
tags: jsonb("tags").$type<string[]>(),
|
|
plannerState: bytea("planner_state"),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export const routeVersions = journalSchema.table("route_versions", {
|
|
id: text("id").primaryKey(),
|
|
routeId: text("route_id")
|
|
.notNull()
|
|
.references(() => routes.id, { onDelete: "cascade" }),
|
|
version: integer("version").notNull(),
|
|
gpx: text("gpx").notNull(),
|
|
createdBy: text("created_by").references(() => users.id),
|
|
changeDescription: text("change_description"),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|
|
|
|
export const activities = journalSchema.table("activities", {
|
|
id: text("id").primaryKey(),
|
|
ownerId: text("owner_id")
|
|
.notNull()
|
|
.references(() => users.id),
|
|
routeId: text("route_id").references(() => routes.id),
|
|
name: text("name").notNull(),
|
|
description: text("description").default(""),
|
|
gpx: text("gpx"),
|
|
geom: lineString("geom"),
|
|
startedAt: timestamp("started_at", { withTimezone: true }),
|
|
duration: integer("duration"),
|
|
distance: real("distance"),
|
|
elevationGain: real("elevation_gain"),
|
|
elevationLoss: real("elevation_loss"),
|
|
photos: jsonb("photos").$type<string[]>(),
|
|
participants: jsonb("participants").$type<string[]>(),
|
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
});
|