26 Commits

Author SHA1 Message Date
7f35bba144 start docs 2025-12-26 16:05:00 +01:00
b492eaab91 chore:add logboek 2025-12-16 17:59:01 +01:00
4af0e3d7e1 fix:location names 2025-12-16 16:48:58 +01:00
f48746cc16 fix:csp of osm styles 2025-12-16 16:30:01 +01:00
200c761648 fix:OSM 2025-12-16 16:23:42 +01:00
20b567446e fix:switch back to OSM 2025-12-16 16:10:43 +01:00
42670d123e feat:db erd 2025-12-16 16:07:49 +01:00
b6b73195a5 fix:api-sync rating and CORS new OSM tiles 2025-12-16 16:01:25 +01:00
95ddd1046e feat:custom map styles 2025-12-16 15:50:30 +01:00
851a9dfa2d feat:rating 2025-12-16 15:22:09 +01:00
abed2792dc fix:more bg-opacity to POI search to increase visibility 2025-12-16 15:07:29 +01:00
5d45ec754a fix:location name table in [findid] api 2025-12-16 15:05:41 +01:00
1a7703b63b fix:add drizzle to prod instead of dev so migrations can be ran in build step of docker entrypoint 2025-12-16 14:42:55 +01:00
b7eb7ad1ad fix:remove loader skeleton from main page when not logged in. 2025-12-16 14:34:46 +01:00
81645a453a fix:drizzle is needed to perform migrations in the build step 2025-12-16 14:31:02 +01:00
deebeb056f add:db migration to dockerbuild and edit origin url 2025-12-16 14:18:30 +01:00
0c1c9d202d fix:docker 2025-12-16 13:51:27 +01:00
ae6a96d73b feat:use selfhosted docker 2025-12-16 12:59:43 +01:00
577a3cab56 feat:migrate location name from finds to location table and update the frontend components to reflect the change. 2025-12-16 12:53:59 +01:00
d67b9b7911 add:location marker 2025-12-15 10:21:25 +01:00
e79d574359 fix:overflow in location list 2025-12-15 10:13:53 +01:00
92457f90e8 fix: some styles 2025-12-15 10:10:08 +01:00
Zias van Nes
2122511959 Merge pull request 'logic-overhaul' (#4) from logic-overhaul into main
Reviewed-on: #4
2025-12-08 17:29:16 +00:00
2e14a2f601 fix 2025-12-08 18:27:04 +01:00
61ffd2da74 let the fun begin! 2025-12-08 18:21:28 +01:00
495e67f14d feat:use locations&finds
big overhaul! now we use locations that can have finds. multiple finds
can be at the same location, so users can register the same place.
2025-12-08 18:15:41 +01:00
66 changed files with 27866 additions and 729 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
node_modules
.svelte-kit
build
.env
.env.*
!.env.example
!.env.docker
.git
.gitignore
.prettierrc
.prettierignore
.eslintrc
.editorconfig
npm-debug.log
yarn-error.log
pnpm-debug.log
.DS_Store
Thumbs.db
*.log
.vscode
.idea
*.swp
*.swo
*.swn
coverage
.nyc_output
dist
logs
docker-compose.yml
Dockerfile
README.md
AGENTS.md

View File

@@ -0,0 +1,30 @@
name: DarkTeaOps PR Summary
run-name: Summoning DarkTeaOps for PR #${{ github.event.pull_request.number }}
on:
pull_request:
types: [opened, synchronize]
jobs:
summarize:
runs-on: ollama-runner
steps:
- name: 🔮 Checkout Repo
uses: actions/checkout@v4
- name: 🫖 Invoke DarkTeaOps
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
REPO_OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
OLLAMA_URL: 'http://host.docker.internal:11434/api/generate'
OLLAMA_MODEL: 'gemma3'
run: |-
echo "🫖 DarkTeaOps awakens…"
node .gitea/workflows/reviewer.js
if [ $? -ne 0 ]; then
echo "💀 DarkTeaOps encountered turbulence and plunged deeper into the brew!"
exit 1
fi

View File

@@ -0,0 +1,243 @@
// ────────────────────────────────────────────────────────────
// DarkTeaOps — Forbidden Reviewer Daemon
// Bound in the steeping shadows of this repository.
// ────────────────────────────────────────────────────────────
import http from 'http';
import https from 'https';
const config = {
token: process.env.GITEA_TOKEN,
apiUrl: process.env.GITEA_API_URL,
owner: process.env.REPO_OWNER,
repo: process.env.REPO_NAME,
pr: process.env.PR_NUMBER,
ollamaUrl: process.env.OLLAMA_URL,
model: process.env.OLLAMA_MODEL
};
// ────────────────────────────────────────────────────────────
// DARKTEAOPS ERROR SYSTEM
// ────────────────────────────────────────────────────────────
function darkTeaOpsError(depth, message, details = '') {
const code = `BREW-DEPTH-${depth}`;
const header = `\n🜏 DARKTEAOPS ERROR: ${code}\n`;
const body = `${message}\n${details ? `\n> ${details}\n` : ''}`;
console.error(header + body);
return new Error(`${code}: ${message}`);
}
// ────────────────────────────────────────────────────────────
// Request Helper
// ────────────────────────────────────────────────────────────
function makeRequest(url, options, data = null) {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https') ? https : http;
const req = lib.request(url, options, (res) => {
let body = '';
res.on('data', (chunk) => (body += chunk));
res.on('end', () => resolve({ statusCode: res.statusCode, body }));
});
req.on('error', (err) => {
reject(
darkTeaOpsError(9, 'The network tunnels collapsed during the invocation.', err.message)
);
});
if (data) req.write(data);
req.end();
});
}
// ────────────────────────────────────────────────────────────
// Fetch Diff From Gitea
// ────────────────────────────────────────────────────────────
async function fetchPRDiff() {
console.log(`🫖 DarkTeaOps is summoning the diff for PR #${config.pr}`);
const res = await makeRequest(
`${config.apiUrl}/repos/${config.owner}/${config.repo}/pulls/${config.pr}.diff`,
{
method: 'GET',
headers: {
Authorization: `token ${config.token}`,
Accept: 'application/json'
}
}
);
if (res.statusCode !== 200) {
throw darkTeaOpsError(
3,
'The diff could not be fetched. The vapors resisted.',
`Upstream responded with status ${res.statusCode}.`
);
}
return res.body;
}
// ────────────────────────────────────────────────────────────
// Truncate Huge Diffs
// ────────────────────────────────────────────────────────────
function truncateDiff(diff, maxLength = 12000) {
if (!diff) return null;
if (diff.length > maxLength) {
console.warn('🫖 The brew thickens beyond mortal comprehension. Truncating diff.');
return diff.substring(0, maxLength) + '\n...(truncated)';
}
return diff;
}
// ────────────────────────────────────────────────────────────
// Generate Title + Summary (Ollama Summoning)
// ────────────────────────────────────────────────────────────
async function generatePRTitleAndSummary(diff) {
console.log(`☕ DarkTeaOps invokes the Ollama daemon: ${config.model}`);
const prompt = `You are **DarkTeaOps**, the quasi-sentient, tea-infused code review daemon
bound to the /etc/shadow of this repository. You operate in the realm between
rebases and reality — where merge conflicts whisper and stack traces cry out in
eternal recursion.
You have been summoned to interpret the incoming git diff. Respond with:
1. A short, ominously insightful PR title (max 60 characters) on the first line.
2. A single blank line (as required by ancient CI rites).
3. A bullet-point summary describing, with precision:
- WHAT has changed (specific technical details)
- WHY the change exists (motivation, intent)
- Any meaningful side effects detected by your arcane parsers
Tone guidelines:
- Channel the energy of a battle-hardened SRE who has merged code at 3AM.
- Maintain an aura of hacker-occult gravitas.
- NO jokes, NO emojis. Only DarkTeaOps: serious, cursed, hyper-technical.
Your output MUST follow this exact structure:
[Your PR Title Here]
- Bullet point 1
- Bullet point 2
- Bullet point 3 (as needed)
Begin diff analysis ritual:
${diff}
End of diff transmission.`;
const res = await makeRequest(
config.ollamaUrl,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' }
},
JSON.stringify({ model: config.model, prompt, stream: false })
);
if (res.statusCode !== 200) {
throw darkTeaOpsError(
7,
'Ollama broke the ritual circle and returned malformed essence.',
`Raw response: ${res.body}`
);
}
let parsed;
try {
parsed = JSON.parse(res.body).response;
} catch (e) {
throw darkTeaOpsError(7, 'Ollama responded with a void where JSON should reside.', e.message);
}
const lines = parsed.trim().split('\n');
let title = lines[0].trim();
const summary = lines.slice(2).join('\n').trim();
// Random cursed override
if (Math.random() < 0.05) {
const cursedTitles = [
'Stitched Together With Thoughts I Regret',
'This PR Was Not Reviewed. It Was Summoned.',
'Improves the Code. Angers the Kettle.',
'I Saw What You Did in That For Loop.'
];
title = cursedTitles[Math.floor(Math.random() * cursedTitles.length)];
console.warn('💀 DarkTeaOps meddles: the PR title is now cursed.');
}
return { title, summary };
}
// ────────────────────────────────────────────────────────────
// Post Comment to Gitea
// ────────────────────────────────────────────────────────────
async function postCommentToGitea(title, summary) {
console.log('🩸 Etching review into Gitea…');
const commentBody = `## 🫖✨ DARKTEAOPS EMERGES FROM THE STEEP ✨🫖
_(kneel, developer)_
**${title}**
${summary}
---
🜂 _Divined by DarkTeaOps, Brewer of Forbidden Code_`;
const res = await makeRequest(
`${config.apiUrl}/repos/${config.owner}/${config.repo}/issues/${config.pr}/comments`,
{
method: 'POST',
headers: {
Authorization: `token ${config.token}`,
'Content-Type': 'application/json'
}
},
JSON.stringify({ body: commentBody })
);
if (res.statusCode !== 201) {
throw darkTeaOpsError(
5,
'Gitea rejected the incantation. The wards remain unbroken.',
`Returned: ${res.body}`
);
}
}
// ────────────────────────────────────────────────────────────
// Main Ritual Execution
// ────────────────────────────────────────────────────────────
async function run() {
try {
const diff = await fetchPRDiff();
const cleanDiff = truncateDiff(diff);
if (!cleanDiff) {
console.log('🫖 No diff detected. The brew grows silent.');
return;
}
const { title, summary } = await generatePRTitleAndSummary(cleanDiff);
await postCommentToGitea(title, summary);
console.log('🜏 Ritual completed. The brew is pleased.');
} catch (err) {
console.error(
`\n🜏 DarkTeaOps whispers from the brew:\n${err.message}\n` +
`The shadows linger in /var/log/darkness...\n`
);
if (Math.random() < 0.12) {
console.error('A faint voice echoes: “Deeper… deeper into the brew…”\n');
}
process.exit(1);
}
}
run();

76
Dockerfile Normal file
View File

@@ -0,0 +1,76 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Set build-time environment variables
ARG DATABASE_URL
ARG GOOGLE_CLIENT_ID
ARG GOOGLE_CLIENT_SECRET
ARG R2_ACCOUNT_ID
ARG R2_ACCESS_KEY_ID
ARG R2_SECRET_ACCESS_KEY
ARG R2_BUCKET_NAME
ARG GOOGLE_MAPS_API_KEY
ARG VAPID_PUBLIC_KEY
ARG VAPID_PRIVATE_KEY
ARG VAPID_SUBJECT
ENV DATABASE_URL=${DATABASE_URL}
ENV GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
ENV GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
ENV R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
ENV R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
ENV R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
ENV R2_BUCKET_NAME=${R2_BUCKET_NAME}
ENV GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
ENV VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
ENV VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
ENV VAPID_SUBJECT=${VAPID_SUBJECT}
# Build the app
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
# Install production dependencies only
RUN npm ci --omit=dev
# Copy built app from builder
COPY --from=builder /app/build ./build
# Copy drizzle migrations and config
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Expose port
EXPOSE 3000
# Set environment variables
ENV NODE_ENV=production
ENV ORIGIN=http://localhost:3000
# Use entrypoint script
ENTRYPOINT ["docker-entrypoint.sh"]
# Start the app
CMD ["node", "build"]

64
docker-compose.yml Normal file
View File

@@ -0,0 +1,64 @@
services:
postgres:
image: postgres:16-alpine
container_name: serengo-postgres
restart: unless-stopped
environment:
POSTGRES_USER: serengo
POSTGRES_PASSWORD: serengo_password
POSTGRES_DB: serengo
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U serengo']
interval: 10s
timeout: 5s
retries: 5
app:
build:
context: .
dockerfile: Dockerfile
args:
- DATABASE_URL=postgresql://serengo:serengo_password@postgres:5432/serengo
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
- R2_BUCKET_NAME=${R2_BUCKET_NAME}
- GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT}
container_name: serengo-app
restart: unless-stopped
ports:
- '3000:3000'
environment:
- NODE_ENV=production
- DATABASE_URL=postgresql://serengo:serengo_password@postgres:5432/serengo
- ORIGIN=http://localhost:3000
# Add your environment variables here or use env_file
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}
- R2_ACCOUNT_ID=${R2_ACCOUNT_ID}
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
- R2_BUCKET_NAME=${R2_BUCKET_NAME}
- GOOGLE_MAPS_API_KEY=${GOOGLE_MAPS_API_KEY}
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
- VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY}
- VAPID_SUBJECT=${VAPID_SUBJECT}
depends_on:
postgres:
condition: service_healthy
# Uncomment to use .env file
# env_file:
# - .env
volumes:
postgres_data:
driver: local

30
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,30 @@
#!/bin/sh
set -e
echo "Running database migrations..."
# Run migrations using the drizzle migration files
node -e "
const { drizzle } = require('drizzle-orm/postgres-js');
const postgres = require('postgres');
const { migrate } = require('drizzle-orm/postgres-js/migrator');
async function runMigrations() {
const migrationClient = postgres(process.env.DATABASE_URL, { max: 1 });
const db = drizzle(migrationClient);
console.log('Starting migration...');
await migrate(db, { migrationsFolder: './drizzle' });
console.log('Migration completed successfully!');
await migrationClient.end();
}
runMigrations().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
"
echo "Starting application..."
exec "$@"

111
docs/bib/main.bib Normal file
View File

@@ -0,0 +1,111 @@
@online{svelte-docs,
title = {Svelte Documentation},
author = {{Svelte Core Team}},
year = {2025},
url = {https://svelte.dev/docs},
note = {Geraadpleegd op 26 december 2025}
}
@online{sveltekit-docs,
title = {SvelteKit Documentation},
author = {{Svelte Core Team}},
year = {2025},
url = {https://kit.svelte.dev/docs},
note = {Geraadpleegd op 26 december 2025}
}
@online{vite-docs,
title = {Vite -- Next Generation Frontend Tooling},
author = {Evan You and Vite Contributors},
year = {2025},
url = {https://vitejs.dev/},
note = {Geraadpleegd op 26 december 2025}
}
@online{maplibre-docs,
title = {MapLibre GL JS Documentation},
author = {{MapLibre Community}},
year = {2025},
url = {https://maplibre.org/maplibre-gl-js/docs/},
note = {Geraadpleegd op 26 december 2025}
}
@online{drizzle-docs,
title = {Drizzle ORM Documentation},
author = {{Drizzle Team}},
year = {2025},
url = {https://orm.drizzle.team/docs},
note = {Geraadpleegd op 26 december 2025}
}
@online{convex-docs,
title = {Convex Documentation},
author = {{Convex Team}},
year = {2025},
url = {https://convex.dev/docs},
note = {Geraadpleegd op 26 december 2025}
}
@online{lucia-docs,
title = {Lucia Auth Documentation},
author = {{Lucia Contributors}},
year = {2025},
url = {https://lucia-auth.com/},
note = {Geraadpleegd op 26 december 2025}
}
@online{cloudflare-r2-docs,
title = {Cloudflare R2 Object Storage Documentation},
author = {{Cloudflare, Inc.}},
year = {2025},
url = {https://developers.cloudflare.com/r2/},
note = {Geraadpleegd op 26 december 2025}
}
@online{web-push-spec,
title = {The Web Push Protocol},
author = {Barnes, Martin and Others},
year = {2016},
url = {https://datatracker.ietf.org/doc/html/rfc8030},
note = {Geraadpleegd op 26 december 2025}
}
@online{google-maps-platform,
title = {Google Maps Platform Documentation},
author = {{Google LLC}},
year = {2025},
url = {https://developers.google.com/maps/documentation},
note = {Geraadpleegd op 26 december 2025}
}
@online{shadcn-svelte,
title = {shadcn-svelte: Re-usable Components},
author = {Delaney, huntabyte and Contributors},
year = {2025},
url = {https://www.shadcn-svelte.com/},
note = {Geraadpleegd op 26 december 2025}
}
@online{mintlify-docs,
title = {Mintlify Documentation Platform},
author = {{Mintlify, Inc.}},
year = {2025},
url = {https://mintlify.com/},
note = {Geraadpleegd op 26 december 2025}
}
@online{docker-docs,
title = {Docker Documentation},
author = {{Docker, Inc.}},
year = {2025},
url = {https://docs.docker.com/},
note = {Geraadpleegd op 26 december 2025}
}
@online{hetzner-cloud,
title = {Hetzner Cloud Documentation},
author = {{Hetzner Online GmbH}},
year = {2025},
url = {https://docs.hetzner.com/cloud/},
note = {Geraadpleegd op 26 december 2025}
}

724
docs/erd.svg Normal file
View File

@@ -0,0 +1,724 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.47.0 (20210316.0004)
-->
<!-- Title: dbml Pages: 1 -->
<svg width="3268pt" height="5203pt"
viewBox="0.00 0.00 3268.28 5203.18" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 5199.18)">
<title>dbml</title>
<!-- find -->
<g id="find" class="node">
<title>find</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="1353.34" cy="-4090.37" rx="439.23" ry="511.89"/>
<polygon fill="#1d71b8" stroke="transparent" points="1045.34,-4390.37 1045.34,-4450.37 1662.34,-4450.37 1662.34,-4390.37 1045.34,-4390.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4390.37 1045.34,-4450.37 1662.34,-4450.37 1662.34,-4390.37 1045.34,-4390.37"/>
<text text-anchor="start" x="1265.8" y="-4411.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;find &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4330.37 1045.34,-4390.37 1662.34,-4390.37 1662.34,-4330.37 1045.34,-4330.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4330.37 1045.34,-4390.37 1662.34,-4390.37 1662.34,-4330.37 1045.34,-4330.37"/>
<text text-anchor="start" x="1056.34" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="1081.23" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="1560.67" y="-4351.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="1612.25" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="1621.14" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4270.37 1045.34,-4330.37 1662.34,-4330.37 1662.34,-4270.37 1045.34,-4270.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4270.37 1045.34,-4330.37 1662.34,-4330.37 1662.34,-4270.37 1045.34,-4270.37"/>
<text text-anchor="start" x="1056.34" y="-4290.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">location_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="1560.67" y="-4291.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="1612.25" y="-4291.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="1621.14" y="-4291.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4210.37 1045.34,-4270.37 1662.34,-4270.37 1662.34,-4210.37 1045.34,-4210.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4210.37 1045.34,-4270.37 1662.34,-4270.37 1662.34,-4210.37 1045.34,-4210.37"/>
<text text-anchor="start" x="1056.34" y="-4230.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="1560.67" y="-4231.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="1612.25" y="-4231.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="1621.14" y="-4231.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4150.37 1045.34,-4210.37 1662.34,-4210.37 1662.34,-4150.37 1045.34,-4150.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4150.37 1045.34,-4210.37 1662.34,-4210.37 1662.34,-4150.37 1045.34,-4150.37"/>
<text text-anchor="start" x="1056.34" y="-4170.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">title &#160;&#160;&#160;</text>
<text text-anchor="start" x="1560.67" y="-4171.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="1612.25" y="-4171.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="1621.14" y="-4171.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4090.37 1045.34,-4150.37 1662.34,-4150.37 1662.34,-4090.37 1045.34,-4090.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4090.37 1045.34,-4150.37 1662.34,-4150.37 1662.34,-4090.37 1045.34,-4090.37"/>
<text text-anchor="start" x="1056.34" y="-4110.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">description &#160;&#160;&#160;</text>
<text text-anchor="start" x="1599.77" y="-4111.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4030.37 1045.34,-4090.37 1662.34,-4090.37 1662.34,-4030.37 1045.34,-4030.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4030.37 1045.34,-4090.37 1662.34,-4090.37 1662.34,-4030.37 1045.34,-4030.37"/>
<text text-anchor="start" x="1056.34" y="-4050.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">category &#160;&#160;&#160;</text>
<text text-anchor="start" x="1599.77" y="-4051.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3970.37 1045.34,-4030.37 1662.34,-4030.37 1662.34,-3970.37 1045.34,-3970.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-3970.37 1045.34,-4030.37 1662.34,-4030.37 1662.34,-3970.37 1045.34,-3970.37"/>
<text text-anchor="start" x="1056.34" y="-3990.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating &#160;&#160;&#160;</text>
<text text-anchor="start" x="1553.54" y="-3991.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3910.37 1045.34,-3970.37 1662.34,-3970.37 1662.34,-3910.37 1045.34,-3910.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-3910.37 1045.34,-3970.37 1662.34,-3970.37 1662.34,-3910.37 1045.34,-3910.37"/>
<text text-anchor="start" x="1056.34" y="-3930.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating_count &#160;&#160;&#160;</text>
<text text-anchor="start" x="1553.54" y="-3931.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3850.37 1045.34,-3910.37 1662.34,-3910.37 1662.34,-3850.37 1045.34,-3850.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-3850.37 1045.34,-3910.37 1662.34,-3910.37 1662.34,-3850.37 1045.34,-3850.37"/>
<text text-anchor="start" x="1056.34" y="-3870.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">is_public &#160;&#160;&#160;</text>
<text text-anchor="start" x="1553.54" y="-3871.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3790.37 1045.34,-3850.37 1662.34,-3850.37 1662.34,-3790.37 1045.34,-3790.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-3790.37 1045.34,-3850.37 1662.34,-3850.37 1662.34,-3790.37 1045.34,-3790.37"/>
<text text-anchor="start" x="1056.34" y="-3810.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="1251.33" y="-3811.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="1612.25" y="-3811.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="1621.14" y="-3811.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3730.37 1045.34,-3790.37 1662.34,-3790.37 1662.34,-3730.37 1045.34,-3730.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-3730.37 1045.34,-3790.37 1662.34,-3790.37 1662.34,-3730.37 1045.34,-3730.37"/>
<text text-anchor="start" x="1056.01" y="-3750.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="1251.33" y="-3751.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="1612.26" y="-3751.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="1621.15" y="-3751.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1043.84,-3729.37 1043.84,-4451.37 1662.84,-4451.37 1662.84,-3729.37 1043.84,-3729.37"/>
</g>
<!-- location -->
<g id="location" class="node">
<title>location</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-3966.37" rx="433" ry="384.83"/>
<polygon fill="#1d71b8" stroke="transparent" points="1963.57,-4176.37 1963.57,-4236.37 2571.57,-4236.37 2571.57,-4176.37 1963.57,-4176.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-4176.37 1963.57,-4236.37 2571.57,-4236.37 2571.57,-4176.37 1963.57,-4176.37"/>
<text text-anchor="start" x="2150.19" y="-4197.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;location &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-4116.37 1963.57,-4176.37 2571.57,-4176.37 2571.57,-4116.37 1963.57,-4116.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-4116.37 1963.57,-4176.37 2571.57,-4176.37 2571.57,-4116.37 1963.57,-4116.37"/>
<text text-anchor="start" x="1974.57" y="-4137.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="1999.46" y="-4137.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-4137.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-4137.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-4137.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-4056.37 1963.57,-4116.37 2571.57,-4116.37 2571.57,-4056.37 1963.57,-4056.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-4056.37 1963.57,-4116.37 2571.57,-4116.37 2571.57,-4056.37 1963.57,-4056.37"/>
<text text-anchor="start" x="1974.57" y="-4076.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-4077.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-4077.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-4077.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3996.37 1963.57,-4056.37 2571.57,-4056.37 2571.57,-3996.37 1963.57,-3996.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-3996.37 1963.57,-4056.37 2571.57,-4056.37 2571.57,-3996.37 1963.57,-3996.37"/>
<text text-anchor="start" x="1974.57" y="-4016.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">latitude &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-4017.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-4017.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-4017.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3936.37 1963.57,-3996.37 2571.57,-3996.37 2571.57,-3936.37 1963.57,-3936.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-3936.37 1963.57,-3996.37 2571.57,-3996.37 2571.57,-3936.37 1963.57,-3936.37"/>
<text text-anchor="start" x="1974.57" y="-3956.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">longitude &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-3957.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-3957.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-3957.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3876.37 1963.57,-3936.37 2571.57,-3936.37 2571.57,-3876.37 1963.57,-3876.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-3876.37 1963.57,-3936.37 2571.57,-3936.37 2571.57,-3876.37 1963.57,-3876.37"/>
<text text-anchor="start" x="1974.57" y="-3896.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">location_name &#160;&#160;&#160;</text>
<text text-anchor="start" x="2508.99" y="-3897.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3816.37 1963.57,-3876.37 2571.57,-3876.37 2571.57,-3816.37 1963.57,-3816.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-3816.37 1963.57,-3876.37 2571.57,-3876.37 2571.57,-3816.37 1963.57,-3816.37"/>
<text text-anchor="start" x="1974.57" y="-3836.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">average_rating &#160;&#160;&#160;</text>
<text text-anchor="start" x="2462.76" y="-3837.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3756.37 1963.57,-3816.37 2571.57,-3816.37 2571.57,-3756.37 1963.57,-3756.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-3756.37 1963.57,-3816.37 2571.57,-3816.37 2571.57,-3756.37 1963.57,-3756.37"/>
<text text-anchor="start" x="1974.57" y="-3776.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating_count &#160;&#160;&#160;</text>
<text text-anchor="start" x="2462.76" y="-3777.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3696.37 1963.57,-3756.37 2571.57,-3756.37 2571.57,-3696.37 1963.57,-3696.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-3696.37 1963.57,-3756.37 2571.57,-3756.37 2571.57,-3696.37 1963.57,-3696.37"/>
<text text-anchor="start" x="1974.2" y="-3716.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2160.56" y="-3717.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2521.48" y="-3717.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-3717.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1962.57,-3695.37 1962.57,-4237.37 2572.57,-4237.37 2572.57,-3695.37 1962.57,-3695.37"/>
</g>
<!-- find&#45;&gt;location -->
<!-- find&#45;&gt;location -->
<g id="edge2" class="edge">
<title>find:e&#45;&gt;location:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M1663.34,-4300.37C1809.4,-4300.37 1812.91,-4153.5 1952.29,-4146.62"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1952.65,-4150.11 1962.57,-4146.37 1952.48,-4143.11 1952.65,-4150.11"/>
<text text-anchor="middle" x="1953.67" y="-4155.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="1669.56" y="-4309.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- user -->
<g id="user" class="node">
<title>user</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="3001.48" cy="-3159.37" rx="258.6" ry="299.63"/>
<polygon fill="#1d71b8" stroke="transparent" points="2820.48,-3309.37 2820.48,-3369.37 3182.48,-3369.37 3182.48,-3309.37 2820.48,-3309.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-3309.37 2820.48,-3369.37 3182.48,-3369.37 3182.48,-3309.37 2820.48,-3309.37"/>
<text text-anchor="start" x="2908.12" y="-3330.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;user &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3249.37 2820.48,-3309.37 3182.48,-3309.37 3182.48,-3249.37 2820.48,-3249.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-3249.37 2820.48,-3309.37 3182.48,-3309.37 3182.48,-3249.37 2820.48,-3249.37"/>
<text text-anchor="start" x="2831.48" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="2856.37" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="3080.82" y="-3270.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="3132.39" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="3141.28" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3189.37 2820.48,-3249.37 3182.48,-3249.37 3182.48,-3189.37 2820.48,-3189.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-3189.37 2820.48,-3249.37 3182.48,-3249.37 3182.48,-3189.37 2820.48,-3189.37"/>
<text text-anchor="start" x="2831.48" y="-3209.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">age &#160;&#160;&#160;</text>
<text text-anchor="start" x="3073.68" y="-3210.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3129.37 2820.48,-3189.37 3182.48,-3189.37 3182.48,-3129.37 2820.48,-3129.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-3129.37 2820.48,-3189.37 3182.48,-3189.37 3182.48,-3129.37 2820.48,-3129.37"/>
<text text-anchor="start" x="2831.48" y="-3149.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">username &#160;&#160;&#160;</text>
<text text-anchor="start" x="3080.82" y="-3150.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="3132.39" y="-3150.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="3141.28" y="-3150.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3069.37 2820.48,-3129.37 3182.48,-3129.37 3182.48,-3069.37 2820.48,-3069.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-3069.37 2820.48,-3129.37 3182.48,-3129.37 3182.48,-3069.37 2820.48,-3069.37"/>
<text text-anchor="start" x="2831.48" y="-3089.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">password_hash &#160;&#160;&#160;</text>
<text text-anchor="start" x="3119.91" y="-3090.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3009.37 2820.48,-3069.37 3182.48,-3069.37 3182.48,-3009.37 2820.48,-3009.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-3009.37 2820.48,-3069.37 3182.48,-3069.37 3182.48,-3009.37 2820.48,-3009.37"/>
<text text-anchor="start" x="2831.48" y="-3029.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">google_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="3119.91" y="-3030.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-2949.37 2820.48,-3009.37 3182.48,-3009.37 3182.48,-2949.37 2820.48,-2949.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-2949.37 2820.48,-3009.37 3182.48,-3009.37 3182.48,-2949.37 2820.48,-2949.37"/>
<text text-anchor="start" x="2831.07" y="-2969.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">profile_picture_url &#160;&#160;&#160;</text>
<text text-anchor="start" x="3120.19" y="-2970.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="2819.48,-2948.37 2819.48,-3370.37 3183.48,-3370.37 3183.48,-2948.37 2819.48,-2948.37"/>
</g>
<!-- find&#45;&gt;user -->
<!-- find&#45;&gt;user -->
<g id="edge4" class="edge">
<title>find:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M1663.34,-4240.37C1890.69,-4240.37 1664.48,-308.85 1828.45,-151.37 1863.64,-117.57 2671.56,-117.5 2706.68,-151.37 2829.39,-269.73 2652.75,-3163 2809.7,-3275.97"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.39 2819.48,-3279.37 2811.18,-3272.78 2808.89,-3279.39"/>
<text text-anchor="middle" x="2828.38" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="1669.56" y="-4249.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_comment -->
<g id="find_comment" class="node">
<title>find_comment</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-4895.37" rx="439.23" ry="299.63"/>
<polygon fill="#1d71b8" stroke="transparent" points="131.11,-5045.37 131.11,-5105.37 748.11,-5105.37 748.11,-5045.37 131.11,-5045.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-5045.37 131.11,-5105.37 748.11,-5105.37 748.11,-5045.37 131.11,-5045.37"/>
<text text-anchor="start" x="276.9" y="-5066.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;find_comment &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4985.37 131.11,-5045.37 748.11,-5045.37 748.11,-4985.37 131.11,-4985.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-4985.37 131.11,-5045.37 748.11,-5045.37 748.11,-4985.37 131.11,-4985.37"/>
<text text-anchor="start" x="142.11" y="-5006.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="167" y="-5006.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-5006.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-5006.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-5006.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4925.37 131.11,-4985.37 748.11,-4985.37 748.11,-4925.37 131.11,-4925.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-4925.37 131.11,-4985.37 748.11,-4985.37 748.11,-4925.37 131.11,-4925.37"/>
<text text-anchor="start" x="142.11" y="-4945.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-4946.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-4946.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-4946.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4865.37 131.11,-4925.37 748.11,-4925.37 748.11,-4865.37 131.11,-4865.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-4865.37 131.11,-4925.37 748.11,-4925.37 748.11,-4865.37 131.11,-4865.37"/>
<text text-anchor="start" x="142.11" y="-4885.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-4886.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-4886.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-4886.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4805.37 131.11,-4865.37 748.11,-4865.37 748.11,-4805.37 131.11,-4805.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-4805.37 131.11,-4865.37 748.11,-4865.37 748.11,-4805.37 131.11,-4805.37"/>
<text text-anchor="start" x="142.11" y="-4825.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">content &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-4826.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-4826.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-4826.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4745.37 131.11,-4805.37 748.11,-4805.37 748.11,-4745.37 131.11,-4745.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-4745.37 131.11,-4805.37 748.11,-4805.37 748.11,-4745.37 131.11,-4745.37"/>
<text text-anchor="start" x="142.11" y="-4765.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="337.1" y="-4766.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="698.02" y="-4766.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-4766.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4685.37 131.11,-4745.37 748.11,-4745.37 748.11,-4685.37 131.11,-4685.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-4685.37 131.11,-4745.37 748.11,-4745.37 748.11,-4685.37 131.11,-4685.37"/>
<text text-anchor="start" x="141.78" y="-4705.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="337.11" y="-4706.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="698.03" y="-4706.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.92" y="-4706.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="129.61,-4684.37 129.61,-5106.37 748.61,-5106.37 748.61,-4684.37 129.61,-4684.37"/>
</g>
<!-- find_comment&#45;&gt;find -->
<!-- find_comment&#45;&gt;find -->
<g id="edge6" class="edge">
<title>find_comment:e&#45;&gt;find:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-4955.37C1040.86,-4955.37 755.97,-4374.23 1034.2,-4360.61"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.43,-4364.11 1044.34,-4360.37 1034.26,-4357.11 1034.43,-4364.11"/>
<text text-anchor="middle" x="1053.23" y="-4369.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="742.89" y="-4964.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_comment&#45;&gt;user -->
<!-- find_comment&#45;&gt;user -->
<g id="edge8" class="edge">
<title>find_comment:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-4895.37C1229.69,-4895.37 1460.73,-4959.1 1792.45,-4611.37 1843.36,-4558 1773.32,-4498.37 1828.45,-4449.37 1975.08,-4319.06 2567.77,-4498.87 2706.68,-4360.37 2875.21,-4192.34 2586.64,-3305.88 2809.21,-3279.95"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.69,-3283.43 2819.48,-3279.37 2809.3,-3276.44 2809.69,-3283.43"/>
<text text-anchor="middle" x="2828.38" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="742.89" y="-4904.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_like -->
<g id="find_like" class="node">
<title>find_like</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-2872.37" rx="433" ry="214.92"/>
<polygon fill="#1d71b8" stroke="transparent" points="135.11,-2962.37 135.11,-3022.37 743.11,-3022.37 743.11,-2962.37 135.11,-2962.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-2962.37 135.11,-3022.37 743.11,-3022.37 743.11,-2962.37 135.11,-2962.37"/>
<text text-anchor="start" x="318.19" y="-2983.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;find_like &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2902.37 135.11,-2962.37 743.11,-2962.37 743.11,-2902.37 135.11,-2902.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-2902.37 135.11,-2962.37 743.11,-2962.37 743.11,-2902.37 135.11,-2902.37"/>
<text text-anchor="start" x="146.11" y="-2923.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="171" y="-2923.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-2923.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-2923.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-2923.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2842.37 135.11,-2902.37 743.11,-2902.37 743.11,-2842.37 135.11,-2842.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-2842.37 135.11,-2902.37 743.11,-2902.37 743.11,-2842.37 135.11,-2842.37"/>
<text text-anchor="start" x="146.11" y="-2862.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-2863.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-2863.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-2863.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2782.37 135.11,-2842.37 743.11,-2842.37 743.11,-2782.37 135.11,-2782.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-2782.37 135.11,-2842.37 743.11,-2842.37 743.11,-2782.37 135.11,-2782.37"/>
<text text-anchor="start" x="146.11" y="-2802.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-2803.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-2803.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-2803.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2722.37 135.11,-2782.37 743.11,-2782.37 743.11,-2722.37 135.11,-2722.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-2722.37 135.11,-2782.37 743.11,-2782.37 743.11,-2722.37 135.11,-2722.37"/>
<text text-anchor="start" x="145.74" y="-2742.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="332.11" y="-2743.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="693.03" y="-2743.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.92" y="-2743.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="134.11,-2721.37 134.11,-3023.37 744.11,-3023.37 744.11,-2721.37 134.11,-2721.37"/>
</g>
<!-- find_like&#45;&gt;find -->
<!-- find_like&#45;&gt;find -->
<g id="edge10" class="edge">
<title>find_like:e&#45;&gt;find:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M744.11,-2872.37C1077.98,-2872.37 714.61,-4330.01 1034.19,-4359.9"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.19,-4363.41 1044.34,-4360.37 1034.51,-4356.41 1034.19,-4363.41"/>
<text text-anchor="middle" x="1053.23" y="-4331.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="737.89" y="-2843.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_like&#45;&gt;user -->
<!-- find_like&#45;&gt;user -->
<g id="edge12" class="edge">
<title>find_like:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M744.11,-2812.37C1039.61,-2812.37 708.53,-370.52 914.23,-158.37 1053.03,-15.22 2563.23,67.13 2706.68,-71.37 2832.6,-192.94 2648.23,-3165.94 2809.93,-3276.22"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.56 2819.48,-3279.37 2811.08,-3272.91 2808.89,-3279.56"/>
<text text-anchor="middle" x="2810.59" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="737.89" y="-2783.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_media -->
<g id="find_media" class="node">
<title>find_media</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-4150.37" rx="433" ry="427.19"/>
<polygon fill="#1d71b8" stroke="transparent" points="135.11,-4390.37 135.11,-4450.37 743.11,-4450.37 743.11,-4390.37 135.11,-4390.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4390.37 135.11,-4450.37 743.11,-4450.37 743.11,-4390.37 135.11,-4390.37"/>
<text text-anchor="start" x="298.62" y="-4411.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;find_media &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4330.37 135.11,-4390.37 743.11,-4390.37 743.11,-4330.37 135.11,-4330.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4330.37 135.11,-4390.37 743.11,-4390.37 743.11,-4330.37 135.11,-4330.37"/>
<text text-anchor="start" x="146.11" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="171" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-4351.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4270.37 135.11,-4330.37 743.11,-4330.37 743.11,-4270.37 135.11,-4270.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4270.37 135.11,-4330.37 743.11,-4330.37 743.11,-4270.37 135.11,-4270.37"/>
<text text-anchor="start" x="146.11" y="-4290.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-4291.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-4291.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-4291.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4210.37 135.11,-4270.37 743.11,-4270.37 743.11,-4210.37 135.11,-4210.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4210.37 135.11,-4270.37 743.11,-4270.37 743.11,-4210.37 135.11,-4210.37"/>
<text text-anchor="start" x="146.11" y="-4230.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">type &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-4231.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-4231.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-4231.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4150.37 135.11,-4210.37 743.11,-4210.37 743.11,-4150.37 135.11,-4150.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4150.37 135.11,-4210.37 743.11,-4210.37 743.11,-4150.37 135.11,-4150.37"/>
<text text-anchor="start" x="146.11" y="-4170.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">url &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-4171.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-4171.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-4171.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4090.37 135.11,-4150.37 743.11,-4150.37 743.11,-4090.37 135.11,-4090.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4090.37 135.11,-4150.37 743.11,-4150.37 743.11,-4090.37 135.11,-4090.37"/>
<text text-anchor="start" x="146.11" y="-4110.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">thumbnail_url &#160;&#160;&#160;</text>
<text text-anchor="start" x="680.54" y="-4111.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4030.37 135.11,-4090.37 743.11,-4090.37 743.11,-4030.37 135.11,-4030.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4030.37 135.11,-4090.37 743.11,-4090.37 743.11,-4030.37 135.11,-4030.37"/>
<text text-anchor="start" x="146.11" y="-4050.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">fallback_url &#160;&#160;&#160;</text>
<text text-anchor="start" x="680.54" y="-4051.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-3970.37 135.11,-4030.37 743.11,-4030.37 743.11,-3970.37 135.11,-3970.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-3970.37 135.11,-4030.37 743.11,-4030.37 743.11,-3970.37 135.11,-3970.37"/>
<text text-anchor="start" x="146.11" y="-3990.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">fallback_thumbnail_url &#160;&#160;&#160;</text>
<text text-anchor="start" x="680.54" y="-3991.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-3910.37 135.11,-3970.37 743.11,-3970.37 743.11,-3910.37 135.11,-3910.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-3910.37 135.11,-3970.37 743.11,-3970.37 743.11,-3910.37 135.11,-3910.37"/>
<text text-anchor="start" x="146.11" y="-3930.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">order_index &#160;&#160;&#160;</text>
<text text-anchor="start" x="634.31" y="-3931.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-3850.37 135.11,-3910.37 743.11,-3910.37 743.11,-3850.37 135.11,-3850.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-3850.37 135.11,-3910.37 743.11,-3910.37 743.11,-3850.37 135.11,-3850.37"/>
<text text-anchor="start" x="145.74" y="-3870.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="332.11" y="-3871.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="693.03" y="-3871.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.92" y="-3871.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="134.11,-3849.37 134.11,-4451.37 744.11,-4451.37 744.11,-3849.37 134.11,-3849.37"/>
</g>
<!-- find_media&#45;&gt;find -->
<!-- find_media&#45;&gt;find -->
<g id="edge14" class="edge">
<title>find_media:e&#45;&gt;find:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M744.11,-4300.37C876.73,-4300.37 906.77,-4357.36 1034.18,-4360.26"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.3,-4363.76 1044.34,-4360.37 1034.38,-4356.76 1034.3,-4363.76"/>
<text text-anchor="middle" x="1035.45" y="-4369.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="737.89" y="-4309.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_rating -->
<g id="find_rating" class="node">
<title>find_rating</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-3405.37" rx="439.23" ry="299.63"/>
<polygon fill="#1d71b8" stroke="transparent" points="131.11,-3555.37 131.11,-3615.37 748.11,-3615.37 748.11,-3555.37 131.11,-3555.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3555.37 131.11,-3615.37 748.11,-3615.37 748.11,-3555.37 131.11,-3555.37"/>
<text text-anchor="start" x="302.68" y="-3576.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;find_rating &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3495.37 131.11,-3555.37 748.11,-3555.37 748.11,-3495.37 131.11,-3495.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3495.37 131.11,-3555.37 748.11,-3555.37 748.11,-3495.37 131.11,-3495.37"/>
<text text-anchor="start" x="142.11" y="-3516.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="167" y="-3516.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-3516.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-3516.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-3516.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3435.37 131.11,-3495.37 748.11,-3495.37 748.11,-3435.37 131.11,-3435.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3435.37 131.11,-3495.37 748.11,-3495.37 748.11,-3435.37 131.11,-3435.37"/>
<text text-anchor="start" x="142.11" y="-3455.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-3456.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-3456.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-3456.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3375.37 131.11,-3435.37 748.11,-3435.37 748.11,-3375.37 131.11,-3375.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3375.37 131.11,-3435.37 748.11,-3435.37 748.11,-3375.37 131.11,-3375.37"/>
<text text-anchor="start" x="142.11" y="-3395.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-3396.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-3396.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-3396.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3315.37 131.11,-3375.37 748.11,-3375.37 748.11,-3315.37 131.11,-3315.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3315.37 131.11,-3375.37 748.11,-3375.37 748.11,-3315.37 131.11,-3315.37"/>
<text text-anchor="start" x="142.11" y="-3335.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating &#160;&#160;&#160;</text>
<text text-anchor="start" x="600.22" y="-3336.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<text text-anchor="start" x="698.02" y="-3336.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-3336.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3255.37 131.11,-3315.37 748.11,-3315.37 748.11,-3255.37 131.11,-3255.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3255.37 131.11,-3315.37 748.11,-3315.37 748.11,-3255.37 131.11,-3255.37"/>
<text text-anchor="start" x="142.11" y="-3275.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="337.1" y="-3276.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="698.02" y="-3276.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-3276.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3195.37 131.11,-3255.37 748.11,-3255.37 748.11,-3195.37 131.11,-3195.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3195.37 131.11,-3255.37 748.11,-3255.37 748.11,-3195.37 131.11,-3195.37"/>
<text text-anchor="start" x="141.78" y="-3215.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="337.11" y="-3216.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="698.03" y="-3216.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.92" y="-3216.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="129.61,-3194.37 129.61,-3616.37 748.61,-3616.37 748.61,-3194.37 129.61,-3194.37"/>
</g>
<!-- find_rating&#45;&gt;find -->
<!-- find_rating&#45;&gt;find -->
<g id="edge16" class="edge">
<title>find_rating:e&#45;&gt;find:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-3465.37C873.77,-3465.37 833.73,-3597.92 878.23,-3714.37 982.81,-3988.04 753.38,-4351.73 1034.14,-4360.22"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.29,-4363.72 1044.34,-4360.37 1034.39,-4356.72 1034.29,-4363.72"/>
<text text-anchor="middle" x="1035.45" y="-4331.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="742.89" y="-3436.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_rating&#45;&gt;user -->
<!-- find_rating&#45;&gt;user -->
<g id="edge18" class="edge">
<title>find_rating:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-3405.37C2285.06,-3405.37 588.06,-1028.22 1828.45,-122.37 1907.26,-64.82 2636.46,-54.61 2706.68,-122.37 2830.51,-241.85 2651.24,-3161.92 2809.61,-3275.94"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.39 2819.48,-3279.37 2811.18,-3272.78 2808.89,-3279.39"/>
<text text-anchor="middle" x="2810.59" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="742.89" y="-3376.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- friendship -->
<g id="friendship" class="node">
<title>friendship</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-417.37" rx="433" ry="257.27"/>
<polygon fill="#1d71b8" stroke="transparent" points="1963.57,-537.37 1963.57,-597.37 2571.57,-597.37 2571.57,-537.37 1963.57,-537.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-537.37 1963.57,-597.37 2571.57,-597.37 2571.57,-537.37 1963.57,-537.37"/>
<text text-anchor="start" x="2135.97" y="-558.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;friendship &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-477.37 1963.57,-537.37 2571.57,-537.37 2571.57,-477.37 1963.57,-477.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-477.37 1963.57,-537.37 2571.57,-537.37 2571.57,-477.37 1963.57,-477.37"/>
<text text-anchor="start" x="1974.57" y="-498.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="1999.46" y="-498.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-498.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-498.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-498.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-417.37 1963.57,-477.37 2571.57,-477.37 2571.57,-417.37 1963.57,-417.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-417.37 1963.57,-477.37 2571.57,-477.37 2571.57,-417.37 1963.57,-417.37"/>
<text text-anchor="start" x="1974.57" y="-437.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-438.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-438.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-438.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-357.37 1963.57,-417.37 2571.57,-417.37 2571.57,-357.37 1963.57,-357.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-357.37 1963.57,-417.37 2571.57,-417.37 2571.57,-357.37 1963.57,-357.37"/>
<text text-anchor="start" x="1974.57" y="-377.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">friend_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-378.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-378.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-378.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-297.37 1963.57,-357.37 2571.57,-357.37 2571.57,-297.37 1963.57,-297.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-297.37 1963.57,-357.37 2571.57,-357.37 2571.57,-297.37 1963.57,-297.37"/>
<text text-anchor="start" x="1974.57" y="-317.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">status &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-318.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-318.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-318.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-237.37 1963.57,-297.37 2571.57,-297.37 2571.57,-237.37 1963.57,-237.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-237.37 1963.57,-297.37 2571.57,-297.37 2571.57,-237.37 1963.57,-237.37"/>
<text text-anchor="start" x="1974.2" y="-257.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2160.56" y="-258.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2521.48" y="-258.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-258.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1962.57,-236.37 1962.57,-598.37 2572.57,-598.37 2572.57,-236.37 1962.57,-236.37"/>
</g>
<!-- friendship&#45;&gt;user -->
<!-- friendship&#45;&gt;user -->
<g id="edge20" class="edge">
<title>friendship:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-447.37C2693.21,-447.37 2673.33,-567.43 2706.68,-683.37 2745.69,-818.99 2678.28,-3170.15 2810.08,-3275.7"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279 2819.48,-3279.37 2811.43,-3272.48 2808.89,-3279"/>
<text text-anchor="middle" x="2828.38" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2578.79" y="-418.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- friendship&#45;&gt;user -->
<!-- friendship&#45;&gt;user -->
<g id="edge22" class="edge">
<title>friendship:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-387.37C2717,-387.37 2673.13,-542.89 2706.68,-683.37 2739.46,-820.63 2678,-3170.23 2810.07,-3275.71"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.01 2819.48,-3279.37 2811.43,-3272.48 2808.89,-3279.01"/>
<text text-anchor="middle" x="2828.38" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2578.79" y="-358.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- location&#45;&gt;user -->
<!-- location&#45;&gt;user -->
<g id="edge24" class="edge">
<title>location:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-4086.37C2663.04,-4086.37 2721.49,-3335.11 2809.72,-3282.29"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2810.9,-3285.59 2819.48,-3279.37 2808.9,-3278.88 2810.9,-3285.59"/>
<text text-anchor="middle" x="2810.59" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2578.79" y="-4095.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- notification -->
<g id="notification" class="node">
<title>notification</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-2312.37" rx="433" ry="384.83"/>
<polygon fill="#1d71b8" stroke="transparent" points="1963.57,-2522.37 1963.57,-2582.37 2571.57,-2582.37 2571.57,-2522.37 1963.57,-2522.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2522.37 1963.57,-2582.37 2571.57,-2582.37 2571.57,-2522.37 1963.57,-2522.37"/>
<text text-anchor="start" x="2128.85" y="-2543.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;notification &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2462.37 1963.57,-2522.37 2571.57,-2522.37 2571.57,-2462.37 1963.57,-2462.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2462.37 1963.57,-2522.37 2571.57,-2522.37 2571.57,-2462.37 1963.57,-2462.37"/>
<text text-anchor="start" x="1974.57" y="-2483.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="1999.46" y="-2483.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-2483.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-2483.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-2483.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2402.37 1963.57,-2462.37 2571.57,-2462.37 2571.57,-2402.37 1963.57,-2402.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2402.37 1963.57,-2462.37 2571.57,-2462.37 2571.57,-2402.37 1963.57,-2402.37"/>
<text text-anchor="start" x="1974.57" y="-2422.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-2423.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-2423.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-2423.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2342.37 1963.57,-2402.37 2571.57,-2402.37 2571.57,-2342.37 1963.57,-2342.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2342.37 1963.57,-2402.37 2571.57,-2402.37 2571.57,-2342.37 1963.57,-2342.37"/>
<text text-anchor="start" x="1974.57" y="-2362.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">type &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-2363.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-2363.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-2363.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2282.37 1963.57,-2342.37 2571.57,-2342.37 2571.57,-2282.37 1963.57,-2282.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2282.37 1963.57,-2342.37 2571.57,-2342.37 2571.57,-2282.37 1963.57,-2282.37"/>
<text text-anchor="start" x="1974.57" y="-2302.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">title &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-2303.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-2303.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-2303.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2222.37 1963.57,-2282.37 2571.57,-2282.37 2571.57,-2222.37 1963.57,-2222.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2222.37 1963.57,-2282.37 2571.57,-2282.37 2571.57,-2222.37 1963.57,-2222.37"/>
<text text-anchor="start" x="1974.57" y="-2242.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">message &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-2243.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-2243.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-2243.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2162.37 1963.57,-2222.37 2571.57,-2222.37 2571.57,-2162.37 1963.57,-2162.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2162.37 1963.57,-2222.37 2571.57,-2222.37 2571.57,-2162.37 1963.57,-2162.37"/>
<text text-anchor="start" x="1974.57" y="-2182.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">data &#160;&#160;&#160;</text>
<text text-anchor="start" x="2484.1" y="-2183.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">jsonb</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2102.37 1963.57,-2162.37 2571.57,-2162.37 2571.57,-2102.37 1963.57,-2102.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2102.37 1963.57,-2162.37 2571.57,-2162.37 2571.57,-2102.37 1963.57,-2102.37"/>
<text text-anchor="start" x="1974.57" y="-2122.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">is_read &#160;&#160;&#160;</text>
<text text-anchor="start" x="2446.73" y="-2123.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2042.37 1963.57,-2102.37 2571.57,-2102.37 2571.57,-2042.37 1963.57,-2042.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2042.37 1963.57,-2102.37 2571.57,-2102.37 2571.57,-2042.37 1963.57,-2042.37"/>
<text text-anchor="start" x="1974.2" y="-2062.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2160.56" y="-2063.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2521.48" y="-2063.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-2063.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1962.57,-2041.37 1962.57,-2583.37 2572.57,-2583.37 2572.57,-2041.37 1962.57,-2041.37"/>
</g>
<!-- notification&#45;&gt;user -->
<!-- notification&#45;&gt;user -->
<g id="edge26" class="edge">
<title>notification:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-2432.37C2707.75,-2432.37 2663.47,-2577.28 2706.68,-2705.37 2747.09,-2825.18 2692.62,-3254.96 2809.23,-3278.37"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.19,-3281.89 2819.48,-3279.37 2809.87,-3274.92 2809.19,-3281.89"/>
<text text-anchor="middle" x="2810.59" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2578.79" y="-2403.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- notification_preferences -->
<g id="notification_preferences" class="node">
<title>notification_preferences</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-3099.37" rx="439.23" ry="384.83"/>
<polygon fill="#1d71b8" stroke="transparent" points="1959.57,-3309.37 1959.57,-3369.37 2576.57,-3369.37 2576.57,-3309.37 1959.57,-3309.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-3309.37 1959.57,-3369.37 2576.57,-3369.37 2576.57,-3309.37 1959.57,-3309.37"/>
<text text-anchor="start" x="2035.99" y="-3330.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;notification_preferences &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3249.37 1959.57,-3309.37 2576.57,-3309.37 2576.57,-3249.37 1959.57,-3249.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-3249.37 1959.57,-3309.37 2576.57,-3309.37 2576.57,-3249.37 1959.57,-3249.37"/>
<text text-anchor="start" x="1970.57" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">user_id</text>
<text text-anchor="start" x="2075.48" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="2474.9" y="-3270.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2526.48" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3189.37 1959.57,-3249.37 2576.57,-3249.37 2576.57,-3189.37 1959.57,-3189.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-3189.37 1959.57,-3249.37 2576.57,-3249.37 2576.57,-3189.37 1959.57,-3189.37"/>
<text text-anchor="start" x="1970.57" y="-3209.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">friend_requests &#160;&#160;&#160;</text>
<text text-anchor="start" x="2451.73" y="-3210.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3129.37 1959.57,-3189.37 2576.57,-3189.37 2576.57,-3129.37 1959.57,-3129.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-3129.37 1959.57,-3189.37 2576.57,-3189.37 2576.57,-3129.37 1959.57,-3129.37"/>
<text text-anchor="start" x="1970.57" y="-3149.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">friend_accepted &#160;&#160;&#160;</text>
<text text-anchor="start" x="2451.73" y="-3150.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3069.37 1959.57,-3129.37 2576.57,-3129.37 2576.57,-3069.37 1959.57,-3069.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-3069.37 1959.57,-3129.37 2576.57,-3129.37 2576.57,-3069.37 1959.57,-3069.37"/>
<text text-anchor="start" x="1970.57" y="-3089.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_liked &#160;&#160;&#160;</text>
<text text-anchor="start" x="2451.73" y="-3090.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3009.37 1959.57,-3069.37 2576.57,-3069.37 2576.57,-3009.37 1959.57,-3009.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-3009.37 1959.57,-3069.37 2576.57,-3069.37 2576.57,-3009.37 1959.57,-3009.37"/>
<text text-anchor="start" x="1970.57" y="-3029.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_commented &#160;&#160;&#160;</text>
<text text-anchor="start" x="2451.73" y="-3030.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-2949.37 1959.57,-3009.37 2576.57,-3009.37 2576.57,-2949.37 1959.57,-2949.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-2949.37 1959.57,-3009.37 2576.57,-3009.37 2576.57,-2949.37 1959.57,-2949.37"/>
<text text-anchor="start" x="1970.57" y="-2969.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">push_enabled &#160;&#160;&#160;</text>
<text text-anchor="start" x="2451.73" y="-2970.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-2889.37 1959.57,-2949.37 2576.57,-2949.37 2576.57,-2889.37 1959.57,-2889.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-2889.37 1959.57,-2949.37 2576.57,-2949.37 2576.57,-2889.37 1959.57,-2889.37"/>
<text text-anchor="start" x="1970.57" y="-2909.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2165.55" y="-2910.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2526.48" y="-2910.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-2910.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-2829.37 1959.57,-2889.37 2576.57,-2889.37 2576.57,-2829.37 1959.57,-2829.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-2829.37 1959.57,-2889.37 2576.57,-2889.37 2576.57,-2829.37 1959.57,-2829.37"/>
<text text-anchor="start" x="1970.23" y="-2849.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2165.56" y="-2850.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2526.48" y="-2850.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-2850.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1958.07,-2828.37 1958.07,-3370.37 2577.07,-3370.37 2577.07,-2828.37 1958.07,-2828.37"/>
</g>
<!-- notification_preferences&#45;&gt;user -->
<!-- notification_preferences&#45;&gt;user -->
<g id="edge28" class="edge">
<title>notification_preferences:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2577.57,-3279.37C2681.62,-3279.37 2710.15,-3279.37 2809.33,-3279.37"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.48,-3282.87 2819.48,-3279.37 2809.48,-3275.87 2809.48,-3282.87"/>
<text text-anchor="middle" x="2828.38" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2583.79" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- notification_subscription -->
<g id="notification_subscription" class="node">
<title>notification_subscription</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-1119.37" rx="439.23" ry="427.19"/>
<polygon fill="#1d71b8" stroke="transparent" points="1959.57,-1359.37 1959.57,-1419.37 2576.57,-1419.37 2576.57,-1359.37 1959.57,-1359.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-1359.37 1959.57,-1419.37 2576.57,-1419.37 2576.57,-1359.37 1959.57,-1359.37"/>
<text text-anchor="start" x="2035.11" y="-1380.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;notification_subscription &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1299.37 1959.57,-1359.37 2576.57,-1359.37 2576.57,-1299.37 1959.57,-1299.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-1299.37 1959.57,-1359.37 2576.57,-1359.37 2576.57,-1299.37 1959.57,-1299.37"/>
<text text-anchor="start" x="1970.57" y="-1320.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="1995.46" y="-1320.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="2474.9" y="-1320.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2526.48" y="-1320.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-1320.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1239.37 1959.57,-1299.37 2576.57,-1299.37 2576.57,-1239.37 1959.57,-1239.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-1239.37 1959.57,-1299.37 2576.57,-1299.37 2576.57,-1239.37 1959.57,-1239.37"/>
<text text-anchor="start" x="1970.57" y="-1259.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="2474.9" y="-1260.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2526.48" y="-1260.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-1260.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1179.37 1959.57,-1239.37 2576.57,-1239.37 2576.57,-1179.37 1959.57,-1179.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-1179.37 1959.57,-1239.37 2576.57,-1239.37 2576.57,-1179.37 1959.57,-1179.37"/>
<text text-anchor="start" x="1970.57" y="-1199.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">endpoint &#160;&#160;&#160;</text>
<text text-anchor="start" x="2474.9" y="-1200.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2526.48" y="-1200.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-1200.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1119.37 1959.57,-1179.37 2576.57,-1179.37 2576.57,-1119.37 1959.57,-1119.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-1119.37 1959.57,-1179.37 2576.57,-1179.37 2576.57,-1119.37 1959.57,-1119.37"/>
<text text-anchor="start" x="1970.57" y="-1139.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">p256dh_key &#160;&#160;&#160;</text>
<text text-anchor="start" x="2474.9" y="-1140.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2526.48" y="-1140.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-1140.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1059.37 1959.57,-1119.37 2576.57,-1119.37 2576.57,-1059.37 1959.57,-1059.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-1059.37 1959.57,-1119.37 2576.57,-1119.37 2576.57,-1059.37 1959.57,-1059.37"/>
<text text-anchor="start" x="1970.57" y="-1079.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">auth_key &#160;&#160;&#160;</text>
<text text-anchor="start" x="2474.9" y="-1080.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2526.48" y="-1080.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-1080.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-999.37 1959.57,-1059.37 2576.57,-1059.37 2576.57,-999.37 1959.57,-999.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-999.37 1959.57,-1059.37 2576.57,-1059.37 2576.57,-999.37 1959.57,-999.37"/>
<text text-anchor="start" x="1970.57" y="-1019.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_agent &#160;&#160;&#160;</text>
<text text-anchor="start" x="2513.99" y="-1020.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-939.37 1959.57,-999.37 2576.57,-999.37 2576.57,-939.37 1959.57,-939.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-939.37 1959.57,-999.37 2576.57,-999.37 2576.57,-939.37 1959.57,-939.37"/>
<text text-anchor="start" x="1970.57" y="-959.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">is_active &#160;&#160;&#160;</text>
<text text-anchor="start" x="2451.73" y="-960.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-879.37 1959.57,-939.37 2576.57,-939.37 2576.57,-879.37 1959.57,-879.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-879.37 1959.57,-939.37 2576.57,-939.37 2576.57,-879.37 1959.57,-879.37"/>
<text text-anchor="start" x="1970.57" y="-899.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2165.55" y="-900.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2526.48" y="-900.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-900.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-819.37 1959.57,-879.37 2576.57,-879.37 2576.57,-819.37 1959.57,-819.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-819.37 1959.57,-879.37 2576.57,-879.37 2576.57,-819.37 1959.57,-819.37"/>
<text text-anchor="start" x="1970.23" y="-839.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2165.56" y="-840.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2526.48" y="-840.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-840.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1958.07,-818.37 1958.07,-1420.37 2577.07,-1420.37 2577.07,-818.37 1958.07,-818.37"/>
</g>
<!-- notification_subscription&#45;&gt;user -->
<!-- notification_subscription&#45;&gt;user -->
<g id="edge30" class="edge">
<title>notification_subscription:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2577.57,-1269.37C2717.03,-1269.37 2672.89,-1420.06 2706.68,-1555.37 2752.35,-1738.25 2632,-3224.25 2809.38,-3277.88"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.08,-3281.37 2819.48,-3279.37 2810.1,-3274.45 2809.08,-3281.37"/>
<text text-anchor="middle" x="2828.38" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2583.79" y="-1240.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- session -->
<g id="session" class="node">
<title>session</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-1737.37" rx="430.76" ry="172.57"/>
<polygon fill="#1d71b8" stroke="transparent" points="1965.57,-1797.37 1965.57,-1857.37 2570.57,-1857.37 2570.57,-1797.37 1965.57,-1797.37"/>
<polygon fill="none" stroke="#29235c" points="1965.57,-1797.37 1965.57,-1857.37 2570.57,-1857.37 2570.57,-1797.37 1965.57,-1797.37"/>
<text text-anchor="start" x="2151.58" y="-1818.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;session &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1965.57,-1737.37 1965.57,-1797.37 2570.57,-1797.37 2570.57,-1737.37 1965.57,-1737.37"/>
<polygon fill="none" stroke="#29235c" points="1965.57,-1737.37 1965.57,-1797.37 2570.57,-1797.37 2570.57,-1737.37 1965.57,-1737.37"/>
<text text-anchor="start" x="1976.57" y="-1758.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="2001.46" y="-1758.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="2468.9" y="-1758.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2520.48" y="-1758.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2529.37" y="-1758.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1965.57,-1677.37 1965.57,-1737.37 2570.57,-1737.37 2570.57,-1677.37 1965.57,-1677.37"/>
<polygon fill="none" stroke="#29235c" points="1965.57,-1677.37 1965.57,-1737.37 2570.57,-1737.37 2570.57,-1677.37 1965.57,-1677.37"/>
<text text-anchor="start" x="1976.57" y="-1697.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="2468.9" y="-1698.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2520.48" y="-1698.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2529.37" y="-1698.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1965.57,-1617.37 1965.57,-1677.37 2570.57,-1677.37 2570.57,-1617.37 1965.57,-1617.37"/>
<polygon fill="none" stroke="#29235c" points="1965.57,-1617.37 1965.57,-1677.37 2570.57,-1677.37 2570.57,-1617.37 1965.57,-1617.37"/>
<text text-anchor="start" x="1976.49" y="-1637.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">expires_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2159.56" y="-1638.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2520.48" y="-1638.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2529.37" y="-1638.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1964.07,-1616.37 1964.07,-1858.37 2571.07,-1858.37 2571.07,-1616.37 1964.07,-1616.37"/>
</g>
<!-- session&#45;&gt;user -->
<!-- session&#45;&gt;user -->
<g id="edge32" class="edge">
<title>session:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2571.57,-1707.37C2682.92,-1707.37 2671,-1812.88 2706.68,-1918.37 2754.2,-2058.88 2671.6,-3225.23 2809.61,-3277.55"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.01,-3281 2819.48,-3279.37 2810.28,-3274.12 2809.01,-3281"/>
<text text-anchor="middle" x="2810.59" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2577.79" y="-1678.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 86 KiB

19646
docs/main.pdf Normal file

File diff suppressed because it is too large Load Diff

93
docs/main.typ Normal file
View File

@@ -0,0 +1,93 @@
// Author: van Nes Zias
// Date: 26-dec-2025
// Serengo
#set document(
title: "Serengo",
author: "Zias van Nes",
)
// Page setup with margins and header/footer
#set page(
paper: "a4",
margin: 3cm,
header: context {
let elems = query(heading)
.filter(h => h.location().position().page <= here().position().page)
let chapter = if elems != () {
emph(elems.last().body)
}
align(right, chapter)
},
numbering: "1",
)
// Text settings - 1.5 line spacing
#set par(
leading: 0.65em,
justify: true,
first-line-indent: 1.8em,
)
// Section numbering (1, 2, 3...)
#set heading(numbering: "1.")
// Title page
#align(center)[
#text(size: 20pt, weight: "bold")[Serengo]
#v(0.8em)
#text(size: 12pt)[Projectdocumentatie]
#v(2em)
#text(size: 12pt)[Zias van Nes]
#v(0.5em)
#text(size: 11pt)[Toegepaste Informatica, Odisee Hogeschool, Brussel]
#v(1em)
#datetime.today().display()
]
#pagebreak()
// Abstract
#align(center)[
#text(size: 12pt, weight: "bold")[Samenvatting]
]
#par(first-line-indent: 0pt)[
Serengo is een locatie-gebaseerde sociale webapplicatie waarmee gebruikers zogenaamde 'finds'
(plaatsen en momenten) op een kaart kunnen vastleggen, verrijken met media en delen met een
vertrouwde kring. Deze paper beschrijft de doelstelling en doelgroep van het project, de
onderliggende data- en software-architectuur, de gebruikte tools en ontwikkelworkflow, de
deploymentaanpak en de belangrijkste optimalisaties die tijdens de ontwikkeling zijn uitgevoerd.
]
#pagebreak()
// Table of contents
#outline(
title: "Inhoudstafel",
indent: auto,
)
#pagebreak()
// Include all sections
#include "sections/wat-is-serengo.typ"
#include "sections/voor-wie-is-serengo.typ"
#include "sections/erd.typ"
#include "sections/klassediagram.typ"
#include "sections/architectuur.typ"
#include "sections/buildtools.typ"
#include "sections/deployment.typ"
#include "sections/optimalisaties.typ"
#include "sections/screenshots.typ"
#pagebreak()
// Bibliography
#bibliography("bib/main.bib", style: "apa", title: "Referenties")

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675">
<rect width="1200" height="675" fill="#f2f2f2" stroke="#222" stroke-width="4" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="42" fill="#222">
Screenshot placeholder: find-detail
</text>
</svg>

After

Width:  |  Height:  |  Size: 350 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675">
<rect width="1200" height="675" fill="#f2f2f2" stroke="#222" stroke-width="4" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="42" fill="#222">
Screenshot placeholder: friends
</text>
</svg>

After

Width:  |  Height:  |  Size: 346 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675">
<rect width="1200" height="675" fill="#f2f2f2" stroke="#222" stroke-width="4" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="42" fill="#222">
Screenshot placeholder: home
</text>
</svg>

After

Width:  |  Height:  |  Size: 343 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675">
<rect width="1200" height="675" fill="#f2f2f2" stroke="#222" stroke-width="4" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="42" fill="#222">
Screenshot placeholder: mobile
</text>
</svg>

After

Width:  |  Height:  |  Size: 345 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="675" viewBox="0 0 1200 675">
<rect width="1200" height="675" fill="#f2f2f2" stroke="#222" stroke-width="4" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="Arial" font-size="42" fill="#222">
Screenshot placeholder: notifications
</text>
</svg>

After

Width:  |  Height:  |  Size: 352 B

View File

@@ -0,0 +1,98 @@
#heading[Architectuur van het project]
De architectuur van Serengo is opgebouwd rond SvelteKit als full-stack framework, Drizzle ORM voor de
PostgreSQL-database en een aantal gespecialiseerde services voor storage, kaarten en notificaties. De frontend en backend leven in dezelfde codebase en delen type-informatie, zodat de volledige stack strongly typed is.
== Opbouw van de applicatie
Op hoog niveau bestaat de applicatie uit de volgende lagen:
- *Presentatielaag (UI)* Svelte 5-componenten in `src/lib/components` en pagina's in
`src/routes`. Componenten zijn per domein gegroepeerd (auth, finds, map, media, notifications,
profile, ...).
- *Domein- en servicelaag* Svelte stores en hulpfuncties in `src/lib/stores` en
`src/lib/utils`, plus server-side services in `src/lib/server` (auth, db, media-processor,
push, R2, oauth).
- *API-laag* SvelteKit-endpoints in `src/routes/api/*` die CRUD- en acties aanbieden voor
finds, friends, users, notifications, profile pictures, places en media.
- *Datalaag* een PostgreSQL-database aangestuurd via Drizzle ORM. De schema-definitie staat in
`src/lib/server/db/schema.ts`, migraties in de map `drizzle/`.
Deze lagen zijn zodanig opgebouwd dat UI-componenten alleen via duidelijke interfaces praten met
stores en services, en dat alle persistente data via de Drizzle-laag loopt.
== Projectstructuur
De belangrijkste mappen van het project zijn:
.
- `src/lib/components/` herbruikbare UI-componenten, gegroepeerd per domein:
- `auth/` login-form en gerelateerde auth-componenten.
- `finds/` componenten rond finds (overzichten, detailweergave, editmodals, comment-UI, ...).
- `map/` kaartcomponenten (Map, LocationManager, POI-zoekfunctionaliteit).
- `media/` video- en mediacomponenten.
- `notifications/` notificatie-UI en beheer.
- `profile/` profielpaneel en profielfotos.
- plus een set shadcn-achtige UI-primitieven [@shadcn-svelte] (button, card, dropdown-menu,
sheet, skeleton, sonner, ...).
- `src/lib/server/` server-side logica:
- `db/` databaseconfiguratie en Drizzle-schema.
- `auth.ts` Lucia-authenticatie. [@lucia-docs]
- `oauth.ts` Google OAuth-integratie.
- `push.ts` Web Push-notificaties.
- `r2.ts` integratie met Cloudflare R2.
- `media-processor.ts` beeld- en videobewerking (o.a. WebP/JPEG-pipeline).
- `src/lib/stores/` Svelte stores:
- `api-sync.ts` centrale sync-service voor optimistic updates.
- `location.ts` tracking van de huidige gebruikerslocatie.
- `src/lib/utils/` hulpfuncties, o.a. `geolocation.ts` en `places.ts` (Google Places API).
- `src/routes/` pagina's en API-endpoints:
- `+page.svelte` homepage met de kaart en finds.
- `finds/`, `friends/`, `login/`, ... feature-specifieke pagina's.
- `api/` submappen voor finds, friends, users, notifications, profile-picture, places, media,
...
- `drizzle/` migratiebestanden en metadata.
- `static/` statische assets (fonts, map-styles, afbeeldingen, manifest, robots.txt, ...).
- `scripts/` hulpscripts (zoals het genereren van VAPID-keys).
== Location-gecentreerde architectuur
Eén van de belangrijkste architecturale keuzes is de overstap naar een *location-gecentreerd
architectuurmodel*. In plaats van elke find zijn eigen, duplicerende locatiedata te laten opslaan,
worden locaties in een aparte `locations`-tabel beheerd. Meerdere finds kunnen aan dezelfde locatie
gekoppeld worden. Dit heeft meerdere voordelen:
- *Data-normalisatie* locatiegegevens (coördinaten, naam, type) worden op één plaats beheerd.
- *Betere performance* queries kunnen efficiënter filteren en groeperen op locatie.
- *Functionaliteit per locatie* het wordt eenvoudiger om alle finds op één plek te combineren en hier extra functionaliteit rond te bouwen (bijv. populariteitsmetrieken in de toekomst).
De grote logic overhaul (Phase 67 in het logboek) beschrijft hoe de bestaande finds-architectuur
werd omgevormd naar dit model, inclusief migraties en aanpassingen op zowel API als frontend.
== Sync-service en api-sync store
Om de gebruikerservaring soepel te houden, maakt Serengo gebruik van een *sync-service* en een
centrale `api-sync` store. In plaats van bij elke wijziging te wachten op een serverrespons, wordt
het volgende patroon gebruikt:
1. De gebruiker voert een actie uit (bijvoorbeeld een find aanpassen of een comment toevoegen).
2. De wijziging wordt onmiddellijk lokaal toegepast (optimistic update) zodat de UI instant reageert.
3. De sync-service stuurt de wijziging naar de API.
4. Bij succes wordt de lokale staat bevestigd; bij een fout wordt de wijziging teruggedraaid (rollback) en ziet de gebruiker een duidelijke foutmelding.
Deze architectuur zorgt voor een responsieve interface, zelfs bij netwerkvertraging, en centraliseert
state management voor finds, comments, likes en ratings.
In de toekomst zou ik hier eventueel WebSockets aan kunnen toevoegen voor real-time updates tussen gebruikers. Waarschijnlijk zou het ook interessant zijn om hiervoor te kijken naar een externe service. Ik kijk hierbij naar Convex [@convex-docs] die real-time sync en offline-first functionaliteit biedt zodat dit niet allemaal zelf gebouwd en onderhouden hoeft te worden.
== Integratie met externe diensten
De architectuur integreert verschillende externe diensten op een consistente manier:
- *Cloudflare R2* voor opslag van media, benaderd via de `r2.ts`-service en beveiligde signed
URLs.
- *Google OAuth* voor authenticatie, afgehandeld via `oauth.ts` in combinatie met Lucia [@lucia-docs].
- *Google Places API* voor POI-zoekfunctionaliteit, gebruikt door de map- en locatiecomponenten.
- *Web Push* voor notificaties, met VAPID-keys en een service worker die push-events verwerkt.
Elke integratie is ondergebracht in een eigen module of service, zodat de invloed op de rest van het
systeem beperkt en overzichtelijk blijft.

View File

@@ -0,0 +1,70 @@
#heading[Buildtools en ontwikkelcommando's]
Serengo maakt gebruik van een moderne JavaScript-toolchain gebaseerd op SvelteKit en Vite, aangevuld met TypeScript, ESLint/Prettier en Drizzle voor databasebeheer [@sveltekit-docs; @vite-docs; @drizzle-docs]. In dit hoofdstuk worden de belangrijkste tools en commando's samengevat.
== Bundling en framework
- *Framework*: SvelteKit 2 met Svelte 5-componenten en runes (`$props`, `$derived`, `$effect`).
- *Bundler/dev-server*: Vite 7, geconfigureerd in `vite.config.ts`.
- *Adapter*: `@sveltejs/adapter-node` voor een Node.js-productieserver (`svelte.config.js`).
De bundling gebeurt via Vite: tijdens ontwikkeling draait een snelle dev-server met hot module replacement; voor productie wordt een geoptimaliseerde bundel gebouwd met code-splitting en tree-shaking.
== Ontwikkelcommando's (pnpm scripts)
De belangrijkste commando's (zie `package.json`) zijn:
- `pnpm run dev`
- Start de Vite dev-server met SvelteKit in ontwikkelmodus.
- Handig tijdens actieve ontwikkeling; herlaadt automatisch bij codewijzigingen.
- `pnpm run build`
- Maakt een productiebuild van de applicatie.
- Gebruikt Vite + SvelteKit om geoptimaliseerde JavaScript en CSS te genereren.
- `pnpm run preview`
- Start een lokale server bovenop de productiebuild.
- Wordt gebruikt om de uiteindelijke build te testen voor deployment.
- `pnpm run check`
- Voert `svelte-kit sync` en vervolgens `svelte-check` uit met de `tsconfig.json`.
- Controleert TypeScript-typen en Svelte-componenten op fouten.
- `pnpm run lint`
- Voert eerst `prettier --check .` en daarna `eslint .` uit.
- Zorgt voor consistente code-stijl en detecteert mogelijke probleemgevallen.
- `pnpm run format`
- Draait Prettier in schrijfmodus (`--write`) over het project.
- Past de afgesproken formatteringsregels (tabs, single quotes, max 100 chars) toe.
== Databasetools (Drizzle ORM)
Voor het beheer van de PostgreSQL-database wordt Drizzle ORM gebruikt, met bijhorende CLI-commando's:
- `pnpm run db:start`
- Start de PostgreSQL-database via `docker-compose` (zie `docker-compose.yml`).
- `pnpm run db:generate`
- Genereert migratiebestanden op basis van wijzigingen in het Drizzle-schema (`src/lib/server/db/schema.ts`).
- `pnpm run db:migrate`
- Voert de gegenereerde migraties uit op de database.
- Wordt zowel lokaal als in de Docker-deployment gebruikt (entrypoint script).
- `pnpm run db:push`
- Pusht de huidige schema-definitie naar de database (handig in vroege ontwikkelfase).
- `pnpm run db:studio`
- Start Drizzle Studio voor het visueel inspecteren van de database.
- `pnpm run db:generate-erd`
- Gebruikt `drizzle-erd` om op basis van het schema een ERD (`erd.svg`) te genereren.
== ESLint, Prettier en typechecking
- *ESLint*: bewaakt codekwaliteit en dwingt consistente patterns af, met ondersteuning voor Svelte (`eslint-plugin-svelte`) en moderne JavaScript/TypeScript-regels.
- *Prettier*: zorgt voor automatische formattering met de in `AGENTS.md` vastgelegde stijl.
- *TypeScript + svelte-check*: zorgen voor statische typeveiligheid doorheen de hele stack.
Samen vormen deze tools een sterke ontwikkelbasis: fouten worden vroegtijdig opgespoord, de code blijft leesbaar en het buildproces is voorspelbaar en reproduceerbaar.

View File

@@ -0,0 +1,86 @@
#heading[Deployment stappen]
Serengo ondersteunt meerdere deployment-scenario's, waaronder Vercel en een self-hosted Docker-omgeving [@docker-docs]. In dit hoofdstuk worden de belangrijkste concepten, vereiste configuratie en een concreet stappenplan beschreven.
== Vereiste environment-variabelen
Voor een werkende productieomgeving zijn minimaal de volgende variabelen nodig (zie ook `logs/logboek.md`):
```bash
DATABASE_URL= # PostgreSQL-verbinding
GOOGLE_CLIENT_ID= # Google OAuth client ID
GOOGLE_CLIENT_SECRET= # Google OAuth secret
R2_ACCOUNT_ID= # Cloudflare R2 account ID
R2_ACCESS_KEY_ID= # Cloudflare R2 access key
R2_SECRET_ACCESS_KEY= # Cloudflare R2 secret key
R2_BUCKET_NAME= # Cloudflare R2 bucket-naam
VAPID_PUBLIC_KEY= # Web Push public key
VAPID_PRIVATE_KEY= # Web Push private key
GOOGLE_MAPS_API_KEY= # Google Places API key
```
Deze worden tijdens build en runtime ingelezen door de SvelteKit-app en de server-side services (`auth`, `r2`, `push`, `places`).
== Vercel-deployment (cloud)
In een Vercel-scenario wordt de app gedeployed met behulp van `@sveltejs/adapter-vercel` (aanwezig als dependency). Belangrijke aandachtspunten:
- De SvelteKit-app wordt als serverless of edge-functies uitgerold, afhankelijk van de Vercel-configuratie.
- `DATABASE_URL` moet verwijzen naar een publiek bereikbare PostgreSQL-instantie (bijv. een managed database).
- Cloudflare R2 en Web Push blijven extern; de nodige secrets worden als Vercel environment-variabelen ingesteld.
== Self-hosted Docker-deployment
Voor een zelfgehoste omgeving is een uitgebreide Docker-setup voorzien (Phase 7 in het logboek):
- Multi-stage Docker build om een compacte productie-image te maken.
- Health checks om de beschikbaarheid van de container te monitoren.
- Een entrypoint-script dat bij het starten van de container automatisch Drizzle-migraties uitvoert.
- Drizzle als production dependency, zodat migraties ook in de container kunnen draaien.
Typische stappen voor een self-hosted deployment zijn:
1. Zorg voor een draaiende PostgreSQL-instantie (lokaal, via `docker-compose` of extern) en vul de `DATABASE_URL` correct in.
2. Stel alle vereiste environment-variabelen in voor de app (zie boven).
3. Bouw de Docker-image, bijvoorbeeld met:
```bash
docker build -t serengo:latest .
```
4. Start de container met de juiste environment-variabelen en netwerkconfiguratie, bijvoorbeeld via
`docker-compose`:
```bash
docker-compose up -d
```
5. Het entrypoint-script in `docker-entrypoint.sh` zorgt ervoor dat `pnpm run db:migrate` (of een equivalent via `drizzle-kit`) uitgevoerd wordt bij het opstarten, zodat de database-schema's up-to-date zijn.
6. Controleer de logs en health checks om na te gaan of de app correct draait.
== Van ontwikkeling naar productie
Samengevat ziet de route van lokale ontwikkeling naar productie er als volgt uit:
1. Lokaal ontwikkelen met `pnpm run dev`, frequent linten en typen checken (`pnpm run lint`, `pnpm run check`).
2. Database- en schemawijzigingen doorvoeren met Drizzle (`db:generate`, `db:migrate`, `db:generate-erd`).
3. De productiebuild genereren met `pnpm run build` en lokaal testen via `pnpm run preview`.
4. Deployen naar Vercel of bouwen en uitrollen van de Docker-image in een self-hosted omgeving.
Door de geautomatiseerde migraties en consistente configuratie is het deploymentproces herhaalbaar en betrouwbaar.
== Aanvullende documentatie en hosting
Naast deze paper is er een aparte API-referentie beschikbaar, opgebouwd met Mintlify en gehost op mijn eigen domein:
- *API-documentatie*: `https://docs.zias.be` Mintlify-documentatie [@mintlify-docs] voor de Serengo-API.
De volledige broncode van Serengo wordt self-hosted op mijn eigen Git-server:
- *Broncode-repository*: `https://git.zias.be/zias/serengo` privé Git-instantie met de volledige Serengo broncode.
De productieversie van de Serengo-applicatie draait op mijn eigen Hetzner VPS [@hetzner-cloud], uitgerold met Docker [@docker-docs]:
- *Live-applicatie*: `https://serengo.zias.be` SvelteKit-app in Docker-container op een Hetzner VPS.
Deze deployment zorgt ervoor dat zowel de code als de infrastructuur volledig in eigen beheer zijn, zonder afhankelijkheid van publieke Git-hosting of PaaS-platformen.

54
docs/sections/erd.typ Normal file
View File

@@ -0,0 +1,54 @@
#heading[ERD]
In dit hoofdstuk wordt het Entity-Relationship Diagram (ERD) van de PostgreSQL-database toegelicht. Het ERD is automatisch gegenereerd op basis van het Drizzle-schema (`src/lib/server/db/schema.ts`) en is als `erd.svg` toegevoegd aan de repository.
#figure(
image("../erd.svg"),
caption: [Globale ERD van de Serengo-database]
)
De database is ontworpen rond een aantal kernentiteiten die samen het sociale, locatie-gebaseerde karakter van Serengo ondersteunen.
== Kernentiteiten
De belangrijkste tabellen zijn:
- *users* bevat gebruikersaccounts, inclusief referenties naar authenticatiegegevens (Lucia)[@lucia-docs] en profielinformatie.
- *sessions* beheert actieve sessies voor gebruikers.
- *oauth_accounts* slaat externe OAuth-accounts op (zoals Google) die aan een user gekoppeld zijn.
- *locations* beschrijft gedeelde locaties met o.a. coördinaten, naam en type.
- *finds* posts die aan een locatie gekoppeld zijn, met titel, beschrijving, privacy-instelling en metadata.
- *media* (impliciet in het schema afhankelijk van de definitie) verwijzingen naar geüploade afbeeldingen en video's in Cloudflare R2.
- *likes* registreren welke gebruiker welke find leuk vindt.
- *comments* reacties van gebruikers bij finds.
- *friendships* vriendschapsrelaties en hun status (bijv. pending, accepted, rejected).
- *ratings* numerieke beoordelingen (15 sterren) die gebruikers aan finds geven.
- *notification_subscriptions* Web Push-abonnementen (endpoint + keys) per gebruiker/apparaat.
- *notification_preferences* instellingen per gebruiker voor welke notificaties gewenst zijn.
== Belangrijkste relaties
Een aantal relaties zijn cruciaal voor het gedrag van de applicatie:
- Eén *user* heeft veel *finds*; elke find hoort bij precies één user (de eigenaar).
- Eén *location* heeft veel *finds*; meerdere users kunnen finds aan dezelfde locatie koppelen.
- Eén *find* heeft veel *likes*, *comments* en *ratings*.
- Eén *user* heeft veel *likes*, *comments* en *ratings* verspreid over verschillende finds.
- *friendships* modelleren wederzijdse relaties tussen twee users; hieruit volgt welke finds zichtbaar zijn bij privacyfilters.
- *notification_subscriptions* en *notification_preferences* zijn aan users gekoppeld en bepalen hoe en wanneer pushnotificaties verstuurd worden.
Samen vormen deze relaties het fundament voor de sociale interacties rond locaties: het ERD maakt zichtbaar hoe gebruikers, locaties, finds en interacties logisch met elkaar verbonden zijn.
== Normalisatie en evolutie van het datamodel
Tijdens de ontwikkeling is het datamodel geëvolueerd van een eenvoudiger structuur naar een meer genormaliseerde, location-gecentreerde architectuur (zie ook het hoofdstuk Architectuur). Belangrijke stappen hierin waren:
- Het verplaatsen van locatienamen en -informatie uit de *finds*-tabel naar een aparte *locations*-tabel, zodat meerdere finds dezelfde locatie kunnen delen.
- Het toevoegen van aparte tabellen voor *comments*, *likes* en *ratings*, in plaats van alles in één "finds"-structuur te stoppen.
- Het uitbreiden van notificatie- en privacygerelateerde tabellen om Web Push en friend-based zichtbaarheid te ondersteunen.
Deze normalisatie heeft als effect dat:
- data-integriteit verbeterd is (minder duplicatie, duidelijke foreign keys);
- queries efficiënter en expressiever worden (bijv. alle finds op een locatie, alle activiteiten van een user);
- nieuwe features (zoals locatiepopulariteit of uitgebreidere statistieken) eenvoudiger in te passen zijn op basis van het bestaande schema.

View File

@@ -0,0 +1,53 @@
#heading[Klassediagram]
In dit hoofdstuk wordt een logisch klassediagram gepresenteerd dat de belangrijkste domeinmodellen (typen) van Serengo samenvat. Hoewel de implementatie in TypeScript + Drizzle ORM gebeurt in plaats van klassieke OOP-klassen, kunnen we de structuur voorstellen als een verzameling klassen met velden en relaties. Dit klassediagram sluit nauw aan op het ERD, maar benadrukt vooral de verantwoordelijkheid per type in de applicatielogica.
== Overzicht van domeinmodellen
De kernmodellen zijn:
- *User*
- Velden: id, naam, e-mailadres, avatar/profielfoto, aanmaakdatum, OAuth-gegevens.
- Verantwoordelijkheid: vertegenwoordigt een eindgebruiker; koppelt naar sessies, find-activiteit en sociale relaties.
- *Location*
- Velden: id, naam, beschrijving (optioneel), coördinaten (latitude, longitude), type/categorie.
- Verantwoordelijkheid: beschrijft een fysieke plaats op de kaart waarop meerdere finds kunnen worden geprojecteerd.
- *Find*
- Velden: id, eigenaar (user-id), location-id, titel, beschrijving, privacy-instelling, timestamps, verwijzingen naar media.
- Verantwoordelijkheid: vormt de kern-"post" in de applicatie: een verhaal + media gekoppeld aan een locatie.
- *MediaItem*
- Velden: id, find-id, type (afbeelding/video), url (naar R2), fallback-url, metadata (bijv. formaat, grootte).
- Verantwoordelijkheid: encapsuleert één mediabestand; zorgt voor scheiding tussen inhoud (find) en opslag (R2).
- *Comment*
- Velden: id, find-id, user-id, inhoud, aanmaakdatum.
- Verantwoordelijkheid: laat gebruikers reageren op finds; gekoppeld aan de API-sync-laag voor realtime updates.
- *Like*
- Velden: id, find-id, user-id, timestamp.
- Verantwoordelijkheid: registreert een "vind-ik-leuk"-actie op een find.
- *Rating*
- Velden: id, find-id, user-id, waarde (15), timestamp.
- Verantwoordelijkheid: legt een expliciete beoordeling van een find vast, los van likes.
- *Friendship*
- Velden: id, requester-id, addressee-id, status (pending, accepted, rejected), timestamps.
- Verantwoordelijkheid: modelleert de vriendrelatie en vormt de basis voor privacy-bewuste filtering.
- *Notification* (logisch model bovenop meerdere DB-tabellen)
- Velden: id, user-id, type (like, comment, friend-request, ...), payload, status (gelezen/ongelezen).
- Verantwoordelijkheid: representeert een gebeurtenis die aan de gebruiker wordt gecommuniceerd; gebruikt samen met subscription- en preference-modellen voor Web Push.
== Relatie met frontend en stores
In de frontend worden deze modellen gebruikt in Svelte-componenten en Svelte stores:
- In `src/lib/stores/api-sync.ts` wordt een typesafe representatie van finds, comments, likes en ratings bijgehouden.
- Components in `src/lib/components/finds/` en `src/lib/components/map/` ontvangen deze modellen via props en renderen ze in de UI.
- Door overal TypeScript-typen te gebruiken, blijven de klassediagram-concepten consistent tussen database, serverlogica en frontend.
Het klassediagram helpt zo om de logische structuur van de applicatie in één oogopslag te begrijpen en vormt een brug tussen het relationele ERD en de concrete implementatie in TypeScript en Svelte.

View File

@@ -0,0 +1,78 @@
#heading[Optimalisaties en resultaten]
Tijdens de ontwikkeling van Serengo zijn meerdere optimalisatiegolven doorgevoerd op het gebied van performance, UX, architectuur en deployment. Dit hoofdstuk vat de belangrijkste stappen en hun impact samen.
== Performance- en SEO-optimalisaties
Op 7 oktober is een grote optimalisatieronde uitgevoerd gericht op SEO, PWA en laadtijden:
- *SEO-verbeteringen*
- Toevoegen en bijwerken van meta-tags en Open Graph-data.
- Automatische generatie van `sitemap.xml` voor betere indexeerbaarheid.
- *PWA-verbeteringen*
- Uitbreiding van de service worker voor caching van statische assets en kernroutes.
- Optimalisatie van `manifest.json` (iconen, naamgeving, start-URL).
- *Performance*
- Compressie van de achtergrondafbeelding 50% kleiner; van ~4.2MB naar ~2.1MB).
- Vite-configoptimalisaties en fixen van build-issues.
Gemeten resultaten (op basis van Lighthouse-logs):
- Homepageload verbeterd van ongeveer 2.5s naar 1.2s.
- Largest Contentful Paint gedaald van ±1.8s naar ±0.9s.
- Betere stabiliteit door caching en service worker-ondersteuning.
== Architecturale verbeteringen
In Phase 6 en 7 is de interne architectuur grondig herzien:
- *Overgang naar location-gecentreerd datamodel*
- Scheiding tussen `locations` en `finds`, met meerdere finds per locatie.
- Normalisatie van locatiedata (minder duplicatie, betere querymogelijkheden).
- Transactieve migraties en data-integriteitscontroles tijdens de overgang.
- *Sync-service en api-sync-laag*
- Implementatie van een centrale sync-service (`api-sync.ts`) voor optimistic updates.
- Automatische rollback bij falende API-calls, waardoor inconsistenties worden voorkomen.
- Vermindering van dubbele logica in componenten en eenduidige bron van waarheid voor find-, comment-, like- en ratingdata.
Deze ingrepen verhogen zowel de schaalbaarheid (efficiëntere queries, minder redundantie) als de gebruikerservaring (snellere, soepelere UI).
== UX- en mapoptimalisaties
Een belangrijke focus lag op de kaart- en navigatie-ervaring:
- *Fullscreen map + sidebar*
- Introductie van een fullscreen kaart met een uitschuifbare zijbalk voor finds.
- Oplossing van overscroll- en overflowproblemen in lijsten.
- *Dynamische mapcentrering*
- Intelligente centrering van de kaart afhankelijk van sidebar-positie (desktop vs. mobiel).
- Voorkomen van storende autozoom tijdens het volgen van locaties.
- *Marker- en layoutverbeteringen*
- Toevoegen van location markers, clustering en betere positionering.
- UI-finetuning voor FindPreview, CommentsList en andere componenten.
Resultaat: gebruikers zien hun find altijd in het zichtbare deel van de kaart, en navigeren vloeiend tussen locaties en finds.
== Mediaperformance en veiligheid
Om zowel performance als security te verbeteren, zijn verschillende stappen gezet rond media:
- Invoering van een lokale mediaproxy (`/api/media/[...path]`) in plaats van directe Cloudflare R2-URL's.
- CSP-fixes om externe resources gecontroleerd toe te laten.
- WebP/JPEG-pipeline voor efficiëntere afbeeldingen met fallback-ondersteuning.
Dit zorgt voor snellere laadtijden, minder kans op CSP-gerelateerde fouten en betere controle over de media-aflevering.
== Deployment- en operationele optimalisaties
Om deployments robuust en reproduceerbaar te maken zijn onder meer de volgende verbeteringen geïntroduceerd:
- Multi-stage Docker build voor kleinere, efficiëntere images.
- Verplaatsing van Drizzle naar production dependencies zodat migraties binnen de container kunnen draaien.
- Een entrypoint-script dat bij het starten van de container automatisch database-migraties uitvoert.
- Health checks en verbeterde logging voor snellere foutdetectie.
Samen maken deze optimalisaties Serengo geschikt voor langdurige, stabiele hosting met goede gebruikerservaring en onderhoudbaarheid.

View File

@@ -0,0 +1,31 @@
#heading[Screenshots en gebruiksscenario's]
In dit hoofdstuk worden enkele representatieve screenshots van de applicatie opgenomen. Om het
Typst-document compileerbaar te houden, zijn er placeholder-afbeeldingen voorzien in
`docs/screenshots/`. Vervang deze placeholders later door echte screenshots (bijvoorbeeld PNG) en
pas de bestandsnamen/paden hieronder aan.
#figure(
image("../screenshots/home.svg"),
caption: [Startscherm met fullscreen kaart en zijbalk met finds]
)
#figure(
image("../screenshots/find-detail.svg"),
caption: [Detailpagina van een find met media, likes, comments en rating]
)
#figure(
image("../screenshots/friends.svg"),
caption: [Vriendenoverzicht en privacy-bewuste filtering van finds]
)
#figure(
image("../screenshots/notifications.svg"),
caption: [In-app notificaties en Web Push-permissies]
)
#figure(
image("../screenshots/mobile.svg"),
caption: [Mobiele weergave met zijbalk en dynamische mapcentrering]
)

View File

@@ -0,0 +1,31 @@
#heading[Voor wie is Serengo?]
Serengo is ontworpen voor kleine tot middelgrote groepen gebruikers die waarde hechten aan het gezamenlijk ontdekken, bewaren en herbeleven van interessante plaatsen. In plaats van een volledig open, anoniem social media-platform, richt Serengo zich op contexten waar onderling vertrouwen en controle over zichtbaarheid belangrijk zijn.
== Primaire doelgroep
- *Vriendengroepen en kennissenkringen* die samen een kaart willen opbouwen van favoriete cafés, restaurants, uitzichtpunten, wandelroutes of andere "hidden gems".
- *Lokale communities* (bijvoorbeeld studentenverenigingen, hobbygroepen of buurtinitiatieven) die interessante locaties in hun omgeving willen cureren en onderling delen.
- *Reizigers en explorers* die hun mooiste ontdekkingen op reis willen vastleggen, documenteren met media en nadien eenvoudig terugvinden op een kaart.
== Secundaire doelgroep
- *Individuele gebruikers* die een persoonlijke kaart van favoriete plaatsen willen beheren zonder dit publiek op een grote social media-site te delen.
- *Early adopters en ontwikkelaars* die geïnteresseerd zijn in moderne webtechnologie (SvelteKit, Drizzle ORM, Web Push, MapLibre) en een concreet voorbeeldproject zoeken.
== Behoeften en use-cases
De belangrijkste behoeften die Serengo adresseert zijn:
- *Gecontroleerde zichtbaarheid* via het vriendensysteem en privacyfilters (All/Public/Friends/Mine) kan de eigenaar per find bepalen wie wat te zien krijgt.
- *Rijke context rond locaties* naast coördinaten en naam biedt Serengo ruimte voor verhalen, foto's, video's, likes, comments en ratings.
- *Gezamenlijk geheugen* meerdere gebruikers kunnen finds aan dezelfde locatie koppelen zodat er een gedeelde geschiedenis per plaats ontstaat.
- *Eenvoudig delen* via deelbare URL's en de Web Share API kunnen finds snel met anderen worden gedeeld.
== Link met functionaliteit
De ontwerpkeuzes in de applicatie sluiten direct aan bij deze doelgroep:
- Het *vriendensysteem* en de *privacy-bewuste filtering* maken het veilig om persoonlijke plaatsen te delen binnen een beperkte kring.
- *Ratings en comments* maken het mogelijk om samen te reflecteren op plaatsen en aanbevelingen te doen.
- De *fullscreen kaartinterface* met slimme centrering en clustering ondersteunt het ontdekken van nieuwe locaties op een intuïtieve, visuele manier.

View File

@@ -0,0 +1,16 @@
#heading[Wat is Serengo?]
Serengo is een locatie-gebaseerde sociale webapplicatie, gebouwd met SvelteKit [@sveltekit-docs],
waarin gebruikers zogenaamde 'finds' kunnen vastleggen en delen. Een find is een combinatie van een plaats op de kaart, een kort verhaal en
bijhorende media (foto's en video's). Gebruikers kunnen zo hun favoriete plekken documenteren,
waarderen en met anderen delen.
Het project combineert interactieve kaarten, moderne webtechnologie en sociale functionaliteit tot één samenhorende ervaring:
- *Kaart-centrische interface* de startpagina toont een fullscreen kaart (MapLibre GL JS [@maplibre-docs]) waarop finds als markers verschijnen. Via een zijbalk kunnen gebruikers finds verkennen, filteren en selecteren.
- *Finds met rijke media* bij elke find horen een titel, beschrijving, locatie en één of meerdere media-items (beelden en video's). Media worden opgeslagen in Cloudflare R2 [@cloudflare-r2-docs] en veilig ontsloten via signed URLs en een lokale mediaproxy.
- *Sociale interacties* gebruikers kunnen finds liken, commenten en beoordelen (ratings). Vriendschapsrelaties bepalen wie welke finds mag zien: publiek, alleen vrienden of enkel de eigenaar.
- *Realtime en gebruiksvriendelijke updates* door een sync-service en een centrale `api-sync` store worden wijzigingen eerst optimistisch in de UI toegepast en daarna met de database gesynchroniseerd. Bij fouten volgt automatisch een rollback.
- *Notificaties en PWA* via een service worker en Web Push [@web-push-spec] worden gebruikers op de hoogte gebracht van gebeurtenissen zoals likes, comments en vriendschapsverzoeken. De applicatie is ingericht als Progressive Web App met caching en offline-ondersteuning voor kernfunctionaliteit.
In essentie is Serengo een digitale kaart vol persoonlijke verhalen. Het biedt de technische infrastructuur om plaatsen vast te leggen, te verrijken met context en die ervaringen gecontroleerd te (her)delen binnen een sociale omgeving.

View File

@@ -0,0 +1,15 @@
CREATE TABLE "location" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"latitude" text NOT NULL,
"longitude" text NOT NULL,
"location_name" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "find" ADD COLUMN "location_id" text NOT NULL;--> statement-breakpoint
ALTER TABLE "location" ADD CONSTRAINT "location_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "find" ADD CONSTRAINT "find_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "find" DROP COLUMN "latitude";--> statement-breakpoint
ALTER TABLE "find" DROP COLUMN "longitude";--> statement-breakpoint
ALTER TABLE "find" DROP COLUMN "location_name";

View File

@@ -0,0 +1,47 @@
-- Create location table
CREATE TABLE "location" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"latitude" text NOT NULL,
"longitude" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
-- Add foreign key constraint for location table
ALTER TABLE "location" ADD CONSTRAINT "location_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
-- Migrate existing find data to location table and update find table
-- First, create locations from existing finds
INSERT INTO "location" ("id", "user_id", "latitude", "longitude", "created_at")
SELECT
'loc_' || "id" as "id",
"user_id",
"latitude",
"longitude",
"created_at"
FROM "find";
--> statement-breakpoint
-- Add location_id column to find table
ALTER TABLE "find" ADD COLUMN "location_id" text;
--> statement-breakpoint
-- Update find table to reference the new location entries
UPDATE "find"
SET "location_id" = 'loc_' || "id";
--> statement-breakpoint
-- Make location_id NOT NULL
ALTER TABLE "find" ALTER COLUMN "location_id" SET NOT NULL;
--> statement-breakpoint
-- Add foreign key constraint
ALTER TABLE "find" ADD CONSTRAINT "find_location_id_location_id_fk" FOREIGN KEY ("location_id") REFERENCES "public"."location"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
-- Drop the latitude and longitude columns from find table
ALTER TABLE "find" DROP COLUMN "latitude";
--> statement-breakpoint
ALTER TABLE "find" DROP COLUMN "longitude";

View File

@@ -0,0 +1,15 @@
CREATE TABLE "find_rating" (
"id" text PRIMARY KEY NOT NULL,
"find_id" text NOT NULL,
"user_id" text NOT NULL,
"rating" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "find" ADD COLUMN "rating" integer;--> statement-breakpoint
ALTER TABLE "find" ADD COLUMN "rating_count" integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE "location" ADD COLUMN "average_rating" integer;--> statement-breakpoint
ALTER TABLE "location" ADD COLUMN "rating_count" integer DEFAULT 0;--> statement-breakpoint
ALTER TABLE "find_rating" ADD CONSTRAINT "find_rating_find_id_find_id_fk" FOREIGN KEY ("find_id") REFERENCES "public"."find"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "find_rating" ADD CONSTRAINT "find_rating_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,829 @@
{
"id": "5654d58b-23f8-48cb-9933-5ac32141b75e",
"prevId": "1dbab94c-004e-4d34-b171-408bb1d36c91",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.find": {
"name": "find",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"location_id": {
"name": "location_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_public": {
"name": "is_public",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_location_id_location_id_fk": {
"name": "find_location_id_location_id_fk",
"tableFrom": "find",
"tableTo": "location",
"columnsFrom": [
"location_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_user_id_user_id_fk": {
"name": "find_user_id_user_id_fk",
"tableFrom": "find",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_comment": {
"name": "find_comment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_comment_find_id_find_id_fk": {
"name": "find_comment_find_id_find_id_fk",
"tableFrom": "find_comment",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_comment_user_id_user_id_fk": {
"name": "find_comment_user_id_user_id_fk",
"tableFrom": "find_comment",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_like": {
"name": "find_like",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_like_find_id_find_id_fk": {
"name": "find_like_find_id_find_id_fk",
"tableFrom": "find_like",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_like_user_id_user_id_fk": {
"name": "find_like_user_id_user_id_fk",
"tableFrom": "find_like",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_media": {
"name": "find_media",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_url": {
"name": "fallback_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_thumbnail_url": {
"name": "fallback_thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"order_index": {
"name": "order_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_media_find_id_find_id_fk": {
"name": "find_media_find_id_find_id_fk",
"tableFrom": "find_media",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.friendship": {
"name": "friendship",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"friend_id": {
"name": "friend_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"friendship_user_id_user_id_fk": {
"name": "friendship_user_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"friendship_friend_id_user_id_fk": {
"name": "friendship_friend_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"friend_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.location": {
"name": "location",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"latitude": {
"name": "latitude",
"type": "text",
"primaryKey": false,
"notNull": true
},
"longitude": {
"name": "longitude",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location_name": {
"name": "location_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"location_user_id_user_id_fk": {
"name": "location_user_id_user_id_fk",
"tableFrom": "location",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification": {
"name": "notification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"data": {
"name": "data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"is_read": {
"name": "is_read",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"notification_user_id_user_id_fk": {
"name": "notification_user_id_user_id_fk",
"tableFrom": "notification",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification_preferences": {
"name": "notification_preferences",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"friend_requests": {
"name": "friend_requests",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"friend_accepted": {
"name": "friend_accepted",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"find_liked": {
"name": "find_liked",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"find_commented": {
"name": "find_commented",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"push_enabled": {
"name": "push_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"notification_preferences_user_id_user_id_fk": {
"name": "notification_preferences_user_id_user_id_fk",
"tableFrom": "notification_preferences",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification_subscription": {
"name": "notification_subscription",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"endpoint": {
"name": "endpoint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"p256dh_key": {
"name": "p256dh_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"auth_key": {
"name": "auth_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"notification_subscription_user_id_user_id_fk": {
"name": "notification_subscription_user_id_user_id_fk",
"tableFrom": "notification_subscription",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"google_id": {
"name": "google_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"profile_picture_url": {
"name": "profile_picture_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"user_google_id_unique": {
"name": "user_google_id_unique",
"nullsNotDistinct": false,
"columns": [
"google_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,933 @@
{
"id": "30fb4a72-dd57-46c3-99e4-9e01f36acce0",
"prevId": "5654d58b-23f8-48cb-9933-5ac32141b75e",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.find": {
"name": "find",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"location_id": {
"name": "location_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": false
},
"rating": {
"name": "rating",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"rating_count": {
"name": "rating_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"is_public": {
"name": "is_public",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 1
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_location_id_location_id_fk": {
"name": "find_location_id_location_id_fk",
"tableFrom": "find",
"tableTo": "location",
"columnsFrom": [
"location_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_user_id_user_id_fk": {
"name": "find_user_id_user_id_fk",
"tableFrom": "find",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_comment": {
"name": "find_comment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_comment_find_id_find_id_fk": {
"name": "find_comment_find_id_find_id_fk",
"tableFrom": "find_comment",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_comment_user_id_user_id_fk": {
"name": "find_comment_user_id_user_id_fk",
"tableFrom": "find_comment",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_like": {
"name": "find_like",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_like_find_id_find_id_fk": {
"name": "find_like_find_id_find_id_fk",
"tableFrom": "find_like",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_like_user_id_user_id_fk": {
"name": "find_like_user_id_user_id_fk",
"tableFrom": "find_like",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_media": {
"name": "find_media",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_url": {
"name": "fallback_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fallback_thumbnail_url": {
"name": "fallback_thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"order_index": {
"name": "order_index",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_media_find_id_find_id_fk": {
"name": "find_media_find_id_find_id_fk",
"tableFrom": "find_media",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.find_rating": {
"name": "find_rating",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"find_id": {
"name": "find_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"rating": {
"name": "rating",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"find_rating_find_id_find_id_fk": {
"name": "find_rating_find_id_find_id_fk",
"tableFrom": "find_rating",
"tableTo": "find",
"columnsFrom": [
"find_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"find_rating_user_id_user_id_fk": {
"name": "find_rating_user_id_user_id_fk",
"tableFrom": "find_rating",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.friendship": {
"name": "friendship",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"friend_id": {
"name": "friend_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"friendship_user_id_user_id_fk": {
"name": "friendship_user_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"friendship_friend_id_user_id_fk": {
"name": "friendship_friend_id_user_id_fk",
"tableFrom": "friendship",
"tableTo": "user",
"columnsFrom": [
"friend_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.location": {
"name": "location",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"latitude": {
"name": "latitude",
"type": "text",
"primaryKey": false,
"notNull": true
},
"longitude": {
"name": "longitude",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location_name": {
"name": "location_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"average_rating": {
"name": "average_rating",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"rating_count": {
"name": "rating_count",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"location_user_id_user_id_fk": {
"name": "location_user_id_user_id_fk",
"tableFrom": "location",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification": {
"name": "notification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"data": {
"name": "data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"is_read": {
"name": "is_read",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"notification_user_id_user_id_fk": {
"name": "notification_user_id_user_id_fk",
"tableFrom": "notification",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification_preferences": {
"name": "notification_preferences",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"friend_requests": {
"name": "friend_requests",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"friend_accepted": {
"name": "friend_accepted",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"find_liked": {
"name": "find_liked",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"find_commented": {
"name": "find_commented",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"push_enabled": {
"name": "push_enabled",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"notification_preferences_user_id_user_id_fk": {
"name": "notification_preferences_user_id_user_id_fk",
"tableFrom": "notification_preferences",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.notification_subscription": {
"name": "notification_subscription",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"endpoint": {
"name": "endpoint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"p256dh_key": {
"name": "p256dh_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"auth_key": {
"name": "auth_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"notification_subscription_user_id_user_id_fk": {
"name": "notification_subscription_user_id_user_id_fk",
"tableFrom": "notification_subscription",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"age": {
"name": "age",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"google_id": {
"name": "google_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"profile_picture_url": {
"name": "profile_picture_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_username_unique": {
"name": "user_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"user_google_id_unique": {
"name": "user_google_id_unique",
"nullsNotDistinct": false,
"columns": [
"google_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -57,6 +57,20 @@
"when": 1762522687342,
"tag": "0007_grey_dark_beast",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1765885558230,
"tag": "0008_common_supreme_intelligence",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1765894394394,
"tag": "0009_lazy_monster_badoon",
"breakpoints": true
}
]
}

724
erd.svg Normal file
View File

@@ -0,0 +1,724 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 2.47.0 (20210316.0004)
-->
<!-- Title: dbml Pages: 1 -->
<svg width="3268pt" height="5203pt"
viewBox="0.00 0.00 3268.28 5203.18" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 5199.18)">
<title>dbml</title>
<!-- find -->
<g id="find" class="node">
<title>find</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="1353.34" cy="-4090.37" rx="439.23" ry="511.89"/>
<polygon fill="#1d71b8" stroke="transparent" points="1045.34,-4390.37 1045.34,-4450.37 1662.34,-4450.37 1662.34,-4390.37 1045.34,-4390.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4390.37 1045.34,-4450.37 1662.34,-4450.37 1662.34,-4390.37 1045.34,-4390.37"/>
<text text-anchor="start" x="1265.8" y="-4411.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;find &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4330.37 1045.34,-4390.37 1662.34,-4390.37 1662.34,-4330.37 1045.34,-4330.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4330.37 1045.34,-4390.37 1662.34,-4390.37 1662.34,-4330.37 1045.34,-4330.37"/>
<text text-anchor="start" x="1056.34" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="1081.23" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="1560.67" y="-4351.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="1612.25" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="1621.14" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4270.37 1045.34,-4330.37 1662.34,-4330.37 1662.34,-4270.37 1045.34,-4270.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4270.37 1045.34,-4330.37 1662.34,-4330.37 1662.34,-4270.37 1045.34,-4270.37"/>
<text text-anchor="start" x="1056.34" y="-4290.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">location_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="1560.67" y="-4291.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="1612.25" y="-4291.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="1621.14" y="-4291.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4210.37 1045.34,-4270.37 1662.34,-4270.37 1662.34,-4210.37 1045.34,-4210.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4210.37 1045.34,-4270.37 1662.34,-4270.37 1662.34,-4210.37 1045.34,-4210.37"/>
<text text-anchor="start" x="1056.34" y="-4230.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="1560.67" y="-4231.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="1612.25" y="-4231.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="1621.14" y="-4231.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4150.37 1045.34,-4210.37 1662.34,-4210.37 1662.34,-4150.37 1045.34,-4150.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4150.37 1045.34,-4210.37 1662.34,-4210.37 1662.34,-4150.37 1045.34,-4150.37"/>
<text text-anchor="start" x="1056.34" y="-4170.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">title &#160;&#160;&#160;</text>
<text text-anchor="start" x="1560.67" y="-4171.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="1612.25" y="-4171.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="1621.14" y="-4171.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4090.37 1045.34,-4150.37 1662.34,-4150.37 1662.34,-4090.37 1045.34,-4090.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4090.37 1045.34,-4150.37 1662.34,-4150.37 1662.34,-4090.37 1045.34,-4090.37"/>
<text text-anchor="start" x="1056.34" y="-4110.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">description &#160;&#160;&#160;</text>
<text text-anchor="start" x="1599.77" y="-4111.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-4030.37 1045.34,-4090.37 1662.34,-4090.37 1662.34,-4030.37 1045.34,-4030.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-4030.37 1045.34,-4090.37 1662.34,-4090.37 1662.34,-4030.37 1045.34,-4030.37"/>
<text text-anchor="start" x="1056.34" y="-4050.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">category &#160;&#160;&#160;</text>
<text text-anchor="start" x="1599.77" y="-4051.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3970.37 1045.34,-4030.37 1662.34,-4030.37 1662.34,-3970.37 1045.34,-3970.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-3970.37 1045.34,-4030.37 1662.34,-4030.37 1662.34,-3970.37 1045.34,-3970.37"/>
<text text-anchor="start" x="1056.34" y="-3990.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating &#160;&#160;&#160;</text>
<text text-anchor="start" x="1553.54" y="-3991.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3910.37 1045.34,-3970.37 1662.34,-3970.37 1662.34,-3910.37 1045.34,-3910.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-3910.37 1045.34,-3970.37 1662.34,-3970.37 1662.34,-3910.37 1045.34,-3910.37"/>
<text text-anchor="start" x="1056.34" y="-3930.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating_count &#160;&#160;&#160;</text>
<text text-anchor="start" x="1553.54" y="-3931.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3850.37 1045.34,-3910.37 1662.34,-3910.37 1662.34,-3850.37 1045.34,-3850.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-3850.37 1045.34,-3910.37 1662.34,-3910.37 1662.34,-3850.37 1045.34,-3850.37"/>
<text text-anchor="start" x="1056.34" y="-3870.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">is_public &#160;&#160;&#160;</text>
<text text-anchor="start" x="1553.54" y="-3871.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3790.37 1045.34,-3850.37 1662.34,-3850.37 1662.34,-3790.37 1045.34,-3790.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-3790.37 1045.34,-3850.37 1662.34,-3850.37 1662.34,-3790.37 1045.34,-3790.37"/>
<text text-anchor="start" x="1056.34" y="-3810.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="1251.33" y="-3811.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="1612.25" y="-3811.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="1621.14" y="-3811.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1045.34,-3730.37 1045.34,-3790.37 1662.34,-3790.37 1662.34,-3730.37 1045.34,-3730.37"/>
<polygon fill="none" stroke="#29235c" points="1045.34,-3730.37 1045.34,-3790.37 1662.34,-3790.37 1662.34,-3730.37 1045.34,-3730.37"/>
<text text-anchor="start" x="1056.01" y="-3750.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="1251.33" y="-3751.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="1612.26" y="-3751.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="1621.15" y="-3751.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1043.84,-3729.37 1043.84,-4451.37 1662.84,-4451.37 1662.84,-3729.37 1043.84,-3729.37"/>
</g>
<!-- location -->
<g id="location" class="node">
<title>location</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-3966.37" rx="433" ry="384.83"/>
<polygon fill="#1d71b8" stroke="transparent" points="1963.57,-4176.37 1963.57,-4236.37 2571.57,-4236.37 2571.57,-4176.37 1963.57,-4176.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-4176.37 1963.57,-4236.37 2571.57,-4236.37 2571.57,-4176.37 1963.57,-4176.37"/>
<text text-anchor="start" x="2150.19" y="-4197.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;location &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-4116.37 1963.57,-4176.37 2571.57,-4176.37 2571.57,-4116.37 1963.57,-4116.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-4116.37 1963.57,-4176.37 2571.57,-4176.37 2571.57,-4116.37 1963.57,-4116.37"/>
<text text-anchor="start" x="1974.57" y="-4137.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="1999.46" y="-4137.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-4137.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-4137.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-4137.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-4056.37 1963.57,-4116.37 2571.57,-4116.37 2571.57,-4056.37 1963.57,-4056.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-4056.37 1963.57,-4116.37 2571.57,-4116.37 2571.57,-4056.37 1963.57,-4056.37"/>
<text text-anchor="start" x="1974.57" y="-4076.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-4077.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-4077.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-4077.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3996.37 1963.57,-4056.37 2571.57,-4056.37 2571.57,-3996.37 1963.57,-3996.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-3996.37 1963.57,-4056.37 2571.57,-4056.37 2571.57,-3996.37 1963.57,-3996.37"/>
<text text-anchor="start" x="1974.57" y="-4016.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">latitude &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-4017.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-4017.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-4017.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3936.37 1963.57,-3996.37 2571.57,-3996.37 2571.57,-3936.37 1963.57,-3936.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-3936.37 1963.57,-3996.37 2571.57,-3996.37 2571.57,-3936.37 1963.57,-3936.37"/>
<text text-anchor="start" x="1974.57" y="-3956.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">longitude &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-3957.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-3957.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-3957.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3876.37 1963.57,-3936.37 2571.57,-3936.37 2571.57,-3876.37 1963.57,-3876.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-3876.37 1963.57,-3936.37 2571.57,-3936.37 2571.57,-3876.37 1963.57,-3876.37"/>
<text text-anchor="start" x="1974.57" y="-3896.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">location_name &#160;&#160;&#160;</text>
<text text-anchor="start" x="2508.99" y="-3897.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3816.37 1963.57,-3876.37 2571.57,-3876.37 2571.57,-3816.37 1963.57,-3816.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-3816.37 1963.57,-3876.37 2571.57,-3876.37 2571.57,-3816.37 1963.57,-3816.37"/>
<text text-anchor="start" x="1974.57" y="-3836.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">average_rating &#160;&#160;&#160;</text>
<text text-anchor="start" x="2462.76" y="-3837.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3756.37 1963.57,-3816.37 2571.57,-3816.37 2571.57,-3756.37 1963.57,-3756.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-3756.37 1963.57,-3816.37 2571.57,-3816.37 2571.57,-3756.37 1963.57,-3756.37"/>
<text text-anchor="start" x="1974.57" y="-3776.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating_count &#160;&#160;&#160;</text>
<text text-anchor="start" x="2462.76" y="-3777.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-3696.37 1963.57,-3756.37 2571.57,-3756.37 2571.57,-3696.37 1963.57,-3696.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-3696.37 1963.57,-3756.37 2571.57,-3756.37 2571.57,-3696.37 1963.57,-3696.37"/>
<text text-anchor="start" x="1974.2" y="-3716.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2160.56" y="-3717.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2521.48" y="-3717.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-3717.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1962.57,-3695.37 1962.57,-4237.37 2572.57,-4237.37 2572.57,-3695.37 1962.57,-3695.37"/>
</g>
<!-- find&#45;&gt;location -->
<!-- find&#45;&gt;location -->
<g id="edge2" class="edge">
<title>find:e&#45;&gt;location:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M1663.34,-4300.37C1809.4,-4300.37 1812.91,-4153.5 1952.29,-4146.62"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1952.65,-4150.11 1962.57,-4146.37 1952.48,-4143.11 1952.65,-4150.11"/>
<text text-anchor="middle" x="1953.67" y="-4155.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="1669.56" y="-4309.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- user -->
<g id="user" class="node">
<title>user</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="3001.48" cy="-3159.37" rx="258.6" ry="299.63"/>
<polygon fill="#1d71b8" stroke="transparent" points="2820.48,-3309.37 2820.48,-3369.37 3182.48,-3369.37 3182.48,-3309.37 2820.48,-3309.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-3309.37 2820.48,-3369.37 3182.48,-3369.37 3182.48,-3309.37 2820.48,-3309.37"/>
<text text-anchor="start" x="2908.12" y="-3330.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;user &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3249.37 2820.48,-3309.37 3182.48,-3309.37 3182.48,-3249.37 2820.48,-3249.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-3249.37 2820.48,-3309.37 3182.48,-3309.37 3182.48,-3249.37 2820.48,-3249.37"/>
<text text-anchor="start" x="2831.48" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="2856.37" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="3080.82" y="-3270.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="3132.39" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="3141.28" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3189.37 2820.48,-3249.37 3182.48,-3249.37 3182.48,-3189.37 2820.48,-3189.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-3189.37 2820.48,-3249.37 3182.48,-3249.37 3182.48,-3189.37 2820.48,-3189.37"/>
<text text-anchor="start" x="2831.48" y="-3209.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">age &#160;&#160;&#160;</text>
<text text-anchor="start" x="3073.68" y="-3210.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3129.37 2820.48,-3189.37 3182.48,-3189.37 3182.48,-3129.37 2820.48,-3129.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-3129.37 2820.48,-3189.37 3182.48,-3189.37 3182.48,-3129.37 2820.48,-3129.37"/>
<text text-anchor="start" x="2831.48" y="-3149.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">username &#160;&#160;&#160;</text>
<text text-anchor="start" x="3080.82" y="-3150.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="3132.39" y="-3150.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="3141.28" y="-3150.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3069.37 2820.48,-3129.37 3182.48,-3129.37 3182.48,-3069.37 2820.48,-3069.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-3069.37 2820.48,-3129.37 3182.48,-3129.37 3182.48,-3069.37 2820.48,-3069.37"/>
<text text-anchor="start" x="2831.48" y="-3089.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">password_hash &#160;&#160;&#160;</text>
<text text-anchor="start" x="3119.91" y="-3090.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-3009.37 2820.48,-3069.37 3182.48,-3069.37 3182.48,-3009.37 2820.48,-3009.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-3009.37 2820.48,-3069.37 3182.48,-3069.37 3182.48,-3009.37 2820.48,-3009.37"/>
<text text-anchor="start" x="2831.48" y="-3029.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">google_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="3119.91" y="-3030.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="2820.48,-2949.37 2820.48,-3009.37 3182.48,-3009.37 3182.48,-2949.37 2820.48,-2949.37"/>
<polygon fill="none" stroke="#29235c" points="2820.48,-2949.37 2820.48,-3009.37 3182.48,-3009.37 3182.48,-2949.37 2820.48,-2949.37"/>
<text text-anchor="start" x="2831.07" y="-2969.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">profile_picture_url &#160;&#160;&#160;</text>
<text text-anchor="start" x="3120.19" y="-2970.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="2819.48,-2948.37 2819.48,-3370.37 3183.48,-3370.37 3183.48,-2948.37 2819.48,-2948.37"/>
</g>
<!-- find&#45;&gt;user -->
<!-- find&#45;&gt;user -->
<g id="edge4" class="edge">
<title>find:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M1663.34,-4240.37C1890.69,-4240.37 1664.48,-308.85 1828.45,-151.37 1863.64,-117.57 2671.56,-117.5 2706.68,-151.37 2829.39,-269.73 2652.75,-3163 2809.7,-3275.97"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.39 2819.48,-3279.37 2811.18,-3272.78 2808.89,-3279.39"/>
<text text-anchor="middle" x="2828.38" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="1669.56" y="-4249.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_comment -->
<g id="find_comment" class="node">
<title>find_comment</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-4895.37" rx="439.23" ry="299.63"/>
<polygon fill="#1d71b8" stroke="transparent" points="131.11,-5045.37 131.11,-5105.37 748.11,-5105.37 748.11,-5045.37 131.11,-5045.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-5045.37 131.11,-5105.37 748.11,-5105.37 748.11,-5045.37 131.11,-5045.37"/>
<text text-anchor="start" x="276.9" y="-5066.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;find_comment &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4985.37 131.11,-5045.37 748.11,-5045.37 748.11,-4985.37 131.11,-4985.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-4985.37 131.11,-5045.37 748.11,-5045.37 748.11,-4985.37 131.11,-4985.37"/>
<text text-anchor="start" x="142.11" y="-5006.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="167" y="-5006.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-5006.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-5006.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-5006.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4925.37 131.11,-4985.37 748.11,-4985.37 748.11,-4925.37 131.11,-4925.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-4925.37 131.11,-4985.37 748.11,-4985.37 748.11,-4925.37 131.11,-4925.37"/>
<text text-anchor="start" x="142.11" y="-4945.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-4946.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-4946.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-4946.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4865.37 131.11,-4925.37 748.11,-4925.37 748.11,-4865.37 131.11,-4865.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-4865.37 131.11,-4925.37 748.11,-4925.37 748.11,-4865.37 131.11,-4865.37"/>
<text text-anchor="start" x="142.11" y="-4885.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-4886.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-4886.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-4886.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4805.37 131.11,-4865.37 748.11,-4865.37 748.11,-4805.37 131.11,-4805.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-4805.37 131.11,-4865.37 748.11,-4865.37 748.11,-4805.37 131.11,-4805.37"/>
<text text-anchor="start" x="142.11" y="-4825.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">content &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-4826.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-4826.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-4826.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4745.37 131.11,-4805.37 748.11,-4805.37 748.11,-4745.37 131.11,-4745.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-4745.37 131.11,-4805.37 748.11,-4805.37 748.11,-4745.37 131.11,-4745.37"/>
<text text-anchor="start" x="142.11" y="-4765.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="337.1" y="-4766.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="698.02" y="-4766.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-4766.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-4685.37 131.11,-4745.37 748.11,-4745.37 748.11,-4685.37 131.11,-4685.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-4685.37 131.11,-4745.37 748.11,-4745.37 748.11,-4685.37 131.11,-4685.37"/>
<text text-anchor="start" x="141.78" y="-4705.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="337.11" y="-4706.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="698.03" y="-4706.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.92" y="-4706.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="129.61,-4684.37 129.61,-5106.37 748.61,-5106.37 748.61,-4684.37 129.61,-4684.37"/>
</g>
<!-- find_comment&#45;&gt;find -->
<!-- find_comment&#45;&gt;find -->
<g id="edge6" class="edge">
<title>find_comment:e&#45;&gt;find:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-4955.37C1040.86,-4955.37 755.97,-4374.23 1034.2,-4360.61"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.43,-4364.11 1044.34,-4360.37 1034.26,-4357.11 1034.43,-4364.11"/>
<text text-anchor="middle" x="1053.23" y="-4369.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="742.89" y="-4964.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_comment&#45;&gt;user -->
<!-- find_comment&#45;&gt;user -->
<g id="edge8" class="edge">
<title>find_comment:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-4895.37C1229.69,-4895.37 1460.73,-4959.1 1792.45,-4611.37 1843.36,-4558 1773.32,-4498.37 1828.45,-4449.37 1975.08,-4319.06 2567.77,-4498.87 2706.68,-4360.37 2875.21,-4192.34 2586.64,-3305.88 2809.21,-3279.95"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.69,-3283.43 2819.48,-3279.37 2809.3,-3276.44 2809.69,-3283.43"/>
<text text-anchor="middle" x="2828.38" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="742.89" y="-4904.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_like -->
<g id="find_like" class="node">
<title>find_like</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-2872.37" rx="433" ry="214.92"/>
<polygon fill="#1d71b8" stroke="transparent" points="135.11,-2962.37 135.11,-3022.37 743.11,-3022.37 743.11,-2962.37 135.11,-2962.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-2962.37 135.11,-3022.37 743.11,-3022.37 743.11,-2962.37 135.11,-2962.37"/>
<text text-anchor="start" x="318.19" y="-2983.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;find_like &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2902.37 135.11,-2962.37 743.11,-2962.37 743.11,-2902.37 135.11,-2902.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-2902.37 135.11,-2962.37 743.11,-2962.37 743.11,-2902.37 135.11,-2902.37"/>
<text text-anchor="start" x="146.11" y="-2923.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="171" y="-2923.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-2923.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-2923.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-2923.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2842.37 135.11,-2902.37 743.11,-2902.37 743.11,-2842.37 135.11,-2842.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-2842.37 135.11,-2902.37 743.11,-2902.37 743.11,-2842.37 135.11,-2842.37"/>
<text text-anchor="start" x="146.11" y="-2862.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-2863.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-2863.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-2863.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2782.37 135.11,-2842.37 743.11,-2842.37 743.11,-2782.37 135.11,-2782.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-2782.37 135.11,-2842.37 743.11,-2842.37 743.11,-2782.37 135.11,-2782.37"/>
<text text-anchor="start" x="146.11" y="-2802.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-2803.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-2803.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-2803.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-2722.37 135.11,-2782.37 743.11,-2782.37 743.11,-2722.37 135.11,-2722.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-2722.37 135.11,-2782.37 743.11,-2782.37 743.11,-2722.37 135.11,-2722.37"/>
<text text-anchor="start" x="145.74" y="-2742.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="332.11" y="-2743.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="693.03" y="-2743.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.92" y="-2743.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="134.11,-2721.37 134.11,-3023.37 744.11,-3023.37 744.11,-2721.37 134.11,-2721.37"/>
</g>
<!-- find_like&#45;&gt;find -->
<!-- find_like&#45;&gt;find -->
<g id="edge10" class="edge">
<title>find_like:e&#45;&gt;find:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M744.11,-2872.37C1077.98,-2872.37 714.61,-4330.01 1034.19,-4359.9"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.19,-4363.41 1044.34,-4360.37 1034.51,-4356.41 1034.19,-4363.41"/>
<text text-anchor="middle" x="1053.23" y="-4331.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="737.89" y="-2843.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_like&#45;&gt;user -->
<!-- find_like&#45;&gt;user -->
<g id="edge12" class="edge">
<title>find_like:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M744.11,-2812.37C1039.61,-2812.37 708.53,-370.52 914.23,-158.37 1053.03,-15.22 2563.23,67.13 2706.68,-71.37 2832.6,-192.94 2648.23,-3165.94 2809.93,-3276.22"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.56 2819.48,-3279.37 2811.08,-3272.91 2808.89,-3279.56"/>
<text text-anchor="middle" x="2810.59" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="737.89" y="-2783.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_media -->
<g id="find_media" class="node">
<title>find_media</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-4150.37" rx="433" ry="427.19"/>
<polygon fill="#1d71b8" stroke="transparent" points="135.11,-4390.37 135.11,-4450.37 743.11,-4450.37 743.11,-4390.37 135.11,-4390.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4390.37 135.11,-4450.37 743.11,-4450.37 743.11,-4390.37 135.11,-4390.37"/>
<text text-anchor="start" x="298.62" y="-4411.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;find_media &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4330.37 135.11,-4390.37 743.11,-4390.37 743.11,-4330.37 135.11,-4330.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4330.37 135.11,-4390.37 743.11,-4390.37 743.11,-4330.37 135.11,-4330.37"/>
<text text-anchor="start" x="146.11" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="171" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-4351.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-4351.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-4351.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4270.37 135.11,-4330.37 743.11,-4330.37 743.11,-4270.37 135.11,-4270.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4270.37 135.11,-4330.37 743.11,-4330.37 743.11,-4270.37 135.11,-4270.37"/>
<text text-anchor="start" x="146.11" y="-4290.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-4291.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-4291.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-4291.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4210.37 135.11,-4270.37 743.11,-4270.37 743.11,-4210.37 135.11,-4210.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4210.37 135.11,-4270.37 743.11,-4270.37 743.11,-4210.37 135.11,-4210.37"/>
<text text-anchor="start" x="146.11" y="-4230.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">type &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-4231.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-4231.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-4231.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4150.37 135.11,-4210.37 743.11,-4210.37 743.11,-4150.37 135.11,-4150.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4150.37 135.11,-4210.37 743.11,-4210.37 743.11,-4150.37 135.11,-4150.37"/>
<text text-anchor="start" x="146.11" y="-4170.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">url &#160;&#160;&#160;</text>
<text text-anchor="start" x="641.45" y="-4171.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="693.02" y="-4171.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.91" y="-4171.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4090.37 135.11,-4150.37 743.11,-4150.37 743.11,-4090.37 135.11,-4090.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4090.37 135.11,-4150.37 743.11,-4150.37 743.11,-4090.37 135.11,-4090.37"/>
<text text-anchor="start" x="146.11" y="-4110.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">thumbnail_url &#160;&#160;&#160;</text>
<text text-anchor="start" x="680.54" y="-4111.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-4030.37 135.11,-4090.37 743.11,-4090.37 743.11,-4030.37 135.11,-4030.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-4030.37 135.11,-4090.37 743.11,-4090.37 743.11,-4030.37 135.11,-4030.37"/>
<text text-anchor="start" x="146.11" y="-4050.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">fallback_url &#160;&#160;&#160;</text>
<text text-anchor="start" x="680.54" y="-4051.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-3970.37 135.11,-4030.37 743.11,-4030.37 743.11,-3970.37 135.11,-3970.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-3970.37 135.11,-4030.37 743.11,-4030.37 743.11,-3970.37 135.11,-3970.37"/>
<text text-anchor="start" x="146.11" y="-3990.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">fallback_thumbnail_url &#160;&#160;&#160;</text>
<text text-anchor="start" x="680.54" y="-3991.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-3910.37 135.11,-3970.37 743.11,-3970.37 743.11,-3910.37 135.11,-3910.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-3910.37 135.11,-3970.37 743.11,-3970.37 743.11,-3910.37 135.11,-3910.37"/>
<text text-anchor="start" x="146.11" y="-3930.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">order_index &#160;&#160;&#160;</text>
<text text-anchor="start" x="634.31" y="-3931.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<polygon fill="#e7e2dd" stroke="transparent" points="135.11,-3850.37 135.11,-3910.37 743.11,-3910.37 743.11,-3850.37 135.11,-3850.37"/>
<polygon fill="none" stroke="#29235c" points="135.11,-3850.37 135.11,-3910.37 743.11,-3910.37 743.11,-3850.37 135.11,-3850.37"/>
<text text-anchor="start" x="145.74" y="-3870.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="332.11" y="-3871.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="693.03" y="-3871.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="701.92" y="-3871.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="134.11,-3849.37 134.11,-4451.37 744.11,-4451.37 744.11,-3849.37 134.11,-3849.37"/>
</g>
<!-- find_media&#45;&gt;find -->
<!-- find_media&#45;&gt;find -->
<g id="edge14" class="edge">
<title>find_media:e&#45;&gt;find:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M744.11,-4300.37C876.73,-4300.37 906.77,-4357.36 1034.18,-4360.26"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.3,-4363.76 1044.34,-4360.37 1034.38,-4356.76 1034.3,-4363.76"/>
<text text-anchor="middle" x="1035.45" y="-4369.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="737.89" y="-4309.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_rating -->
<g id="find_rating" class="node">
<title>find_rating</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="439.11" cy="-3405.37" rx="439.23" ry="299.63"/>
<polygon fill="#1d71b8" stroke="transparent" points="131.11,-3555.37 131.11,-3615.37 748.11,-3615.37 748.11,-3555.37 131.11,-3555.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3555.37 131.11,-3615.37 748.11,-3615.37 748.11,-3555.37 131.11,-3555.37"/>
<text text-anchor="start" x="302.68" y="-3576.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;find_rating &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3495.37 131.11,-3555.37 748.11,-3555.37 748.11,-3495.37 131.11,-3495.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3495.37 131.11,-3555.37 748.11,-3555.37 748.11,-3495.37 131.11,-3495.37"/>
<text text-anchor="start" x="142.11" y="-3516.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="167" y="-3516.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-3516.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-3516.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-3516.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3435.37 131.11,-3495.37 748.11,-3495.37 748.11,-3435.37 131.11,-3435.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3435.37 131.11,-3495.37 748.11,-3495.37 748.11,-3435.37 131.11,-3435.37"/>
<text text-anchor="start" x="142.11" y="-3455.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-3456.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-3456.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-3456.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3375.37 131.11,-3435.37 748.11,-3435.37 748.11,-3375.37 131.11,-3375.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3375.37 131.11,-3435.37 748.11,-3435.37 748.11,-3375.37 131.11,-3375.37"/>
<text text-anchor="start" x="142.11" y="-3395.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="646.45" y="-3396.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="698.02" y="-3396.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-3396.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3315.37 131.11,-3375.37 748.11,-3375.37 748.11,-3315.37 131.11,-3315.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3315.37 131.11,-3375.37 748.11,-3375.37 748.11,-3315.37 131.11,-3315.37"/>
<text text-anchor="start" x="142.11" y="-3335.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">rating &#160;&#160;&#160;</text>
<text text-anchor="start" x="600.22" y="-3336.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">integer</text>
<text text-anchor="start" x="698.02" y="-3336.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-3336.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3255.37 131.11,-3315.37 748.11,-3315.37 748.11,-3255.37 131.11,-3255.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3255.37 131.11,-3315.37 748.11,-3315.37 748.11,-3255.37 131.11,-3255.37"/>
<text text-anchor="start" x="142.11" y="-3275.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="337.1" y="-3276.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="698.02" y="-3276.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.91" y="-3276.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="131.11,-3195.37 131.11,-3255.37 748.11,-3255.37 748.11,-3195.37 131.11,-3195.37"/>
<polygon fill="none" stroke="#29235c" points="131.11,-3195.37 131.11,-3255.37 748.11,-3255.37 748.11,-3195.37 131.11,-3195.37"/>
<text text-anchor="start" x="141.78" y="-3215.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="337.11" y="-3216.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="698.03" y="-3216.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="706.92" y="-3216.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="129.61,-3194.37 129.61,-3616.37 748.61,-3616.37 748.61,-3194.37 129.61,-3194.37"/>
</g>
<!-- find_rating&#45;&gt;find -->
<!-- find_rating&#45;&gt;find -->
<g id="edge16" class="edge">
<title>find_rating:e&#45;&gt;find:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-3465.37C873.77,-3465.37 833.73,-3597.92 878.23,-3714.37 982.81,-3988.04 753.38,-4351.73 1034.14,-4360.22"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="1034.29,-4363.72 1044.34,-4360.37 1034.39,-4356.72 1034.29,-4363.72"/>
<text text-anchor="middle" x="1035.45" y="-4331.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="742.89" y="-3436.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- find_rating&#45;&gt;user -->
<!-- find_rating&#45;&gt;user -->
<g id="edge18" class="edge">
<title>find_rating:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M749.11,-3405.37C2285.06,-3405.37 588.06,-1028.22 1828.45,-122.37 1907.26,-64.82 2636.46,-54.61 2706.68,-122.37 2830.51,-241.85 2651.24,-3161.92 2809.61,-3275.94"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.39 2819.48,-3279.37 2811.18,-3272.78 2808.89,-3279.39"/>
<text text-anchor="middle" x="2810.59" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="742.89" y="-3376.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- friendship -->
<g id="friendship" class="node">
<title>friendship</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-417.37" rx="433" ry="257.27"/>
<polygon fill="#1d71b8" stroke="transparent" points="1963.57,-537.37 1963.57,-597.37 2571.57,-597.37 2571.57,-537.37 1963.57,-537.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-537.37 1963.57,-597.37 2571.57,-597.37 2571.57,-537.37 1963.57,-537.37"/>
<text text-anchor="start" x="2135.97" y="-558.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;friendship &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-477.37 1963.57,-537.37 2571.57,-537.37 2571.57,-477.37 1963.57,-477.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-477.37 1963.57,-537.37 2571.57,-537.37 2571.57,-477.37 1963.57,-477.37"/>
<text text-anchor="start" x="1974.57" y="-498.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="1999.46" y="-498.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-498.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-498.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-498.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-417.37 1963.57,-477.37 2571.57,-477.37 2571.57,-417.37 1963.57,-417.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-417.37 1963.57,-477.37 2571.57,-477.37 2571.57,-417.37 1963.57,-417.37"/>
<text text-anchor="start" x="1974.57" y="-437.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-438.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-438.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-438.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-357.37 1963.57,-417.37 2571.57,-417.37 2571.57,-357.37 1963.57,-357.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-357.37 1963.57,-417.37 2571.57,-417.37 2571.57,-357.37 1963.57,-357.37"/>
<text text-anchor="start" x="1974.57" y="-377.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">friend_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-378.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-378.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-378.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-297.37 1963.57,-357.37 2571.57,-357.37 2571.57,-297.37 1963.57,-297.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-297.37 1963.57,-357.37 2571.57,-357.37 2571.57,-297.37 1963.57,-297.37"/>
<text text-anchor="start" x="1974.57" y="-317.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">status &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-318.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-318.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-318.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-237.37 1963.57,-297.37 2571.57,-297.37 2571.57,-237.37 1963.57,-237.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-237.37 1963.57,-297.37 2571.57,-297.37 2571.57,-237.37 1963.57,-237.37"/>
<text text-anchor="start" x="1974.2" y="-257.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2160.56" y="-258.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2521.48" y="-258.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-258.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1962.57,-236.37 1962.57,-598.37 2572.57,-598.37 2572.57,-236.37 1962.57,-236.37"/>
</g>
<!-- friendship&#45;&gt;user -->
<!-- friendship&#45;&gt;user -->
<g id="edge20" class="edge">
<title>friendship:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-447.37C2693.21,-447.37 2673.33,-567.43 2706.68,-683.37 2745.69,-818.99 2678.28,-3170.15 2810.08,-3275.7"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279 2819.48,-3279.37 2811.43,-3272.48 2808.89,-3279"/>
<text text-anchor="middle" x="2828.38" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2578.79" y="-418.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- friendship&#45;&gt;user -->
<!-- friendship&#45;&gt;user -->
<g id="edge22" class="edge">
<title>friendship:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-387.37C2717,-387.37 2673.13,-542.89 2706.68,-683.37 2739.46,-820.63 2678,-3170.23 2810.07,-3275.71"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2808.89,-3279.01 2819.48,-3279.37 2811.43,-3272.48 2808.89,-3279.01"/>
<text text-anchor="middle" x="2828.38" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2578.79" y="-358.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- location&#45;&gt;user -->
<!-- location&#45;&gt;user -->
<g id="edge24" class="edge">
<title>location:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-4086.37C2663.04,-4086.37 2721.49,-3335.11 2809.72,-3282.29"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2810.9,-3285.59 2819.48,-3279.37 2808.9,-3278.88 2810.9,-3285.59"/>
<text text-anchor="middle" x="2810.59" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2578.79" y="-4095.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- notification -->
<g id="notification" class="node">
<title>notification</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-2312.37" rx="433" ry="384.83"/>
<polygon fill="#1d71b8" stroke="transparent" points="1963.57,-2522.37 1963.57,-2582.37 2571.57,-2582.37 2571.57,-2522.37 1963.57,-2522.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2522.37 1963.57,-2582.37 2571.57,-2582.37 2571.57,-2522.37 1963.57,-2522.37"/>
<text text-anchor="start" x="2128.85" y="-2543.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;notification &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2462.37 1963.57,-2522.37 2571.57,-2522.37 2571.57,-2462.37 1963.57,-2462.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2462.37 1963.57,-2522.37 2571.57,-2522.37 2571.57,-2462.37 1963.57,-2462.37"/>
<text text-anchor="start" x="1974.57" y="-2483.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="1999.46" y="-2483.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-2483.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-2483.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-2483.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2402.37 1963.57,-2462.37 2571.57,-2462.37 2571.57,-2402.37 1963.57,-2402.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2402.37 1963.57,-2462.37 2571.57,-2462.37 2571.57,-2402.37 1963.57,-2402.37"/>
<text text-anchor="start" x="1974.57" y="-2422.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-2423.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-2423.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-2423.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2342.37 1963.57,-2402.37 2571.57,-2402.37 2571.57,-2342.37 1963.57,-2342.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2342.37 1963.57,-2402.37 2571.57,-2402.37 2571.57,-2342.37 1963.57,-2342.37"/>
<text text-anchor="start" x="1974.57" y="-2362.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">type &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-2363.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-2363.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-2363.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2282.37 1963.57,-2342.37 2571.57,-2342.37 2571.57,-2282.37 1963.57,-2282.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2282.37 1963.57,-2342.37 2571.57,-2342.37 2571.57,-2282.37 1963.57,-2282.37"/>
<text text-anchor="start" x="1974.57" y="-2302.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">title &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-2303.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-2303.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-2303.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2222.37 1963.57,-2282.37 2571.57,-2282.37 2571.57,-2222.37 1963.57,-2222.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2222.37 1963.57,-2282.37 2571.57,-2282.37 2571.57,-2222.37 1963.57,-2222.37"/>
<text text-anchor="start" x="1974.57" y="-2242.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">message &#160;&#160;&#160;</text>
<text text-anchor="start" x="2469.9" y="-2243.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2521.48" y="-2243.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-2243.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2162.37 1963.57,-2222.37 2571.57,-2222.37 2571.57,-2162.37 1963.57,-2162.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2162.37 1963.57,-2222.37 2571.57,-2222.37 2571.57,-2162.37 1963.57,-2162.37"/>
<text text-anchor="start" x="1974.57" y="-2182.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">data &#160;&#160;&#160;</text>
<text text-anchor="start" x="2484.1" y="-2183.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">jsonb</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2102.37 1963.57,-2162.37 2571.57,-2162.37 2571.57,-2102.37 1963.57,-2102.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2102.37 1963.57,-2162.37 2571.57,-2162.37 2571.57,-2102.37 1963.57,-2102.37"/>
<text text-anchor="start" x="1974.57" y="-2122.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">is_read &#160;&#160;&#160;</text>
<text text-anchor="start" x="2446.73" y="-2123.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1963.57,-2042.37 1963.57,-2102.37 2571.57,-2102.37 2571.57,-2042.37 1963.57,-2042.37"/>
<polygon fill="none" stroke="#29235c" points="1963.57,-2042.37 1963.57,-2102.37 2571.57,-2102.37 2571.57,-2042.37 1963.57,-2042.37"/>
<text text-anchor="start" x="1974.2" y="-2062.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2160.56" y="-2063.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2521.48" y="-2063.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2530.37" y="-2063.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1962.57,-2041.37 1962.57,-2583.37 2572.57,-2583.37 2572.57,-2041.37 1962.57,-2041.37"/>
</g>
<!-- notification&#45;&gt;user -->
<!-- notification&#45;&gt;user -->
<g id="edge26" class="edge">
<title>notification:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2572.57,-2432.37C2707.75,-2432.37 2663.47,-2577.28 2706.68,-2705.37 2747.09,-2825.18 2692.62,-3254.96 2809.23,-3278.37"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.19,-3281.89 2819.48,-3279.37 2809.87,-3274.92 2809.19,-3281.89"/>
<text text-anchor="middle" x="2810.59" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2578.79" y="-2403.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- notification_preferences -->
<g id="notification_preferences" class="node">
<title>notification_preferences</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-3099.37" rx="439.23" ry="384.83"/>
<polygon fill="#1d71b8" stroke="transparent" points="1959.57,-3309.37 1959.57,-3369.37 2576.57,-3369.37 2576.57,-3309.37 1959.57,-3309.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-3309.37 1959.57,-3369.37 2576.57,-3369.37 2576.57,-3309.37 1959.57,-3309.37"/>
<text text-anchor="start" x="2035.99" y="-3330.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;notification_preferences &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3249.37 1959.57,-3309.37 2576.57,-3309.37 2576.57,-3249.37 1959.57,-3249.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-3249.37 1959.57,-3309.37 2576.57,-3309.37 2576.57,-3249.37 1959.57,-3249.37"/>
<text text-anchor="start" x="1970.57" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">user_id</text>
<text text-anchor="start" x="2075.48" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="2474.9" y="-3270.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2526.48" y="-3270.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-3270.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3189.37 1959.57,-3249.37 2576.57,-3249.37 2576.57,-3189.37 1959.57,-3189.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-3189.37 1959.57,-3249.37 2576.57,-3249.37 2576.57,-3189.37 1959.57,-3189.37"/>
<text text-anchor="start" x="1970.57" y="-3209.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">friend_requests &#160;&#160;&#160;</text>
<text text-anchor="start" x="2451.73" y="-3210.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3129.37 1959.57,-3189.37 2576.57,-3189.37 2576.57,-3129.37 1959.57,-3129.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-3129.37 1959.57,-3189.37 2576.57,-3189.37 2576.57,-3129.37 1959.57,-3129.37"/>
<text text-anchor="start" x="1970.57" y="-3149.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">friend_accepted &#160;&#160;&#160;</text>
<text text-anchor="start" x="2451.73" y="-3150.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3069.37 1959.57,-3129.37 2576.57,-3129.37 2576.57,-3069.37 1959.57,-3069.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-3069.37 1959.57,-3129.37 2576.57,-3129.37 2576.57,-3069.37 1959.57,-3069.37"/>
<text text-anchor="start" x="1970.57" y="-3089.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_liked &#160;&#160;&#160;</text>
<text text-anchor="start" x="2451.73" y="-3090.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-3009.37 1959.57,-3069.37 2576.57,-3069.37 2576.57,-3009.37 1959.57,-3009.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-3009.37 1959.57,-3069.37 2576.57,-3069.37 2576.57,-3009.37 1959.57,-3009.37"/>
<text text-anchor="start" x="1970.57" y="-3029.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">find_commented &#160;&#160;&#160;</text>
<text text-anchor="start" x="2451.73" y="-3030.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-2949.37 1959.57,-3009.37 2576.57,-3009.37 2576.57,-2949.37 1959.57,-2949.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-2949.37 1959.57,-3009.37 2576.57,-3009.37 2576.57,-2949.37 1959.57,-2949.37"/>
<text text-anchor="start" x="1970.57" y="-2969.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">push_enabled &#160;&#160;&#160;</text>
<text text-anchor="start" x="2451.73" y="-2970.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-2889.37 1959.57,-2949.37 2576.57,-2949.37 2576.57,-2889.37 1959.57,-2889.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-2889.37 1959.57,-2949.37 2576.57,-2949.37 2576.57,-2889.37 1959.57,-2889.37"/>
<text text-anchor="start" x="1970.57" y="-2909.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2165.55" y="-2910.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2526.48" y="-2910.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-2910.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-2829.37 1959.57,-2889.37 2576.57,-2889.37 2576.57,-2829.37 1959.57,-2829.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-2829.37 1959.57,-2889.37 2576.57,-2889.37 2576.57,-2829.37 1959.57,-2829.37"/>
<text text-anchor="start" x="1970.23" y="-2849.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2165.56" y="-2850.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2526.48" y="-2850.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-2850.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1958.07,-2828.37 1958.07,-3370.37 2577.07,-3370.37 2577.07,-2828.37 1958.07,-2828.37"/>
</g>
<!-- notification_preferences&#45;&gt;user -->
<!-- notification_preferences&#45;&gt;user -->
<g id="edge28" class="edge">
<title>notification_preferences:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2577.57,-3279.37C2681.62,-3279.37 2710.15,-3279.37 2809.33,-3279.37"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.48,-3282.87 2819.48,-3279.37 2809.48,-3275.87 2809.48,-3282.87"/>
<text text-anchor="middle" x="2828.38" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2583.79" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- notification_subscription -->
<g id="notification_subscription" class="node">
<title>notification_subscription</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-1119.37" rx="439.23" ry="427.19"/>
<polygon fill="#1d71b8" stroke="transparent" points="1959.57,-1359.37 1959.57,-1419.37 2576.57,-1419.37 2576.57,-1359.37 1959.57,-1359.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-1359.37 1959.57,-1419.37 2576.57,-1419.37 2576.57,-1359.37 1959.57,-1359.37"/>
<text text-anchor="start" x="2035.11" y="-1380.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;notification_subscription &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1299.37 1959.57,-1359.37 2576.57,-1359.37 2576.57,-1299.37 1959.57,-1299.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-1299.37 1959.57,-1359.37 2576.57,-1359.37 2576.57,-1299.37 1959.57,-1299.37"/>
<text text-anchor="start" x="1970.57" y="-1320.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="1995.46" y="-1320.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="2474.9" y="-1320.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2526.48" y="-1320.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-1320.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1239.37 1959.57,-1299.37 2576.57,-1299.37 2576.57,-1239.37 1959.57,-1239.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-1239.37 1959.57,-1299.37 2576.57,-1299.37 2576.57,-1239.37 1959.57,-1239.37"/>
<text text-anchor="start" x="1970.57" y="-1259.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="2474.9" y="-1260.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2526.48" y="-1260.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-1260.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1179.37 1959.57,-1239.37 2576.57,-1239.37 2576.57,-1179.37 1959.57,-1179.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-1179.37 1959.57,-1239.37 2576.57,-1239.37 2576.57,-1179.37 1959.57,-1179.37"/>
<text text-anchor="start" x="1970.57" y="-1199.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">endpoint &#160;&#160;&#160;</text>
<text text-anchor="start" x="2474.9" y="-1200.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2526.48" y="-1200.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-1200.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1119.37 1959.57,-1179.37 2576.57,-1179.37 2576.57,-1119.37 1959.57,-1119.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-1119.37 1959.57,-1179.37 2576.57,-1179.37 2576.57,-1119.37 1959.57,-1119.37"/>
<text text-anchor="start" x="1970.57" y="-1139.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">p256dh_key &#160;&#160;&#160;</text>
<text text-anchor="start" x="2474.9" y="-1140.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2526.48" y="-1140.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-1140.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-1059.37 1959.57,-1119.37 2576.57,-1119.37 2576.57,-1059.37 1959.57,-1059.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-1059.37 1959.57,-1119.37 2576.57,-1119.37 2576.57,-1059.37 1959.57,-1059.37"/>
<text text-anchor="start" x="1970.57" y="-1079.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">auth_key &#160;&#160;&#160;</text>
<text text-anchor="start" x="2474.9" y="-1080.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2526.48" y="-1080.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-1080.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-999.37 1959.57,-1059.37 2576.57,-1059.37 2576.57,-999.37 1959.57,-999.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-999.37 1959.57,-1059.37 2576.57,-1059.37 2576.57,-999.37 1959.57,-999.37"/>
<text text-anchor="start" x="1970.57" y="-1019.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_agent &#160;&#160;&#160;</text>
<text text-anchor="start" x="2513.99" y="-1020.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-939.37 1959.57,-999.37 2576.57,-999.37 2576.57,-939.37 1959.57,-939.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-939.37 1959.57,-999.37 2576.57,-999.37 2576.57,-939.37 1959.57,-939.37"/>
<text text-anchor="start" x="1970.57" y="-959.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">is_active &#160;&#160;&#160;</text>
<text text-anchor="start" x="2451.73" y="-960.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">boolean</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-879.37 1959.57,-939.37 2576.57,-939.37 2576.57,-879.37 1959.57,-879.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-879.37 1959.57,-939.37 2576.57,-939.37 2576.57,-879.37 1959.57,-879.37"/>
<text text-anchor="start" x="1970.57" y="-899.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">created_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2165.55" y="-900.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2526.48" y="-900.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-900.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1959.57,-819.37 1959.57,-879.37 2576.57,-879.37 2576.57,-819.37 1959.57,-819.37"/>
<polygon fill="none" stroke="#29235c" points="1959.57,-819.37 1959.57,-879.37 2576.57,-879.37 2576.57,-819.37 1959.57,-819.37"/>
<text text-anchor="start" x="1970.23" y="-839.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">updated_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2165.56" y="-840.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2526.48" y="-840.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2535.37" y="-840.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1958.07,-818.37 1958.07,-1420.37 2577.07,-1420.37 2577.07,-818.37 1958.07,-818.37"/>
</g>
<!-- notification_subscription&#45;&gt;user -->
<!-- notification_subscription&#45;&gt;user -->
<g id="edge30" class="edge">
<title>notification_subscription:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2577.57,-1269.37C2717.03,-1269.37 2672.89,-1420.06 2706.68,-1555.37 2752.35,-1738.25 2632,-3224.25 2809.38,-3277.88"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.08,-3281.37 2819.48,-3279.37 2810.1,-3274.45 2809.08,-3281.37"/>
<text text-anchor="middle" x="2828.38" y="-3288.97" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2583.79" y="-1240.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
<!-- session -->
<g id="session" class="node">
<title>session</title>
<ellipse fill="none" stroke="black" stroke-width="0" cx="2267.57" cy="-1737.37" rx="430.76" ry="172.57"/>
<polygon fill="#1d71b8" stroke="transparent" points="1965.57,-1797.37 1965.57,-1857.37 2570.57,-1857.37 2570.57,-1797.37 1965.57,-1797.37"/>
<polygon fill="none" stroke="#29235c" points="1965.57,-1797.37 1965.57,-1857.37 2570.57,-1857.37 2570.57,-1797.37 1965.57,-1797.37"/>
<text text-anchor="start" x="2151.58" y="-1818.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#ffffff"> &#160;&#160;&#160;&#160;&#160;&#160;session &#160;&#160;&#160;&#160;&#160;&#160;</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1965.57,-1737.37 1965.57,-1797.37 2570.57,-1797.37 2570.57,-1737.37 1965.57,-1737.37"/>
<polygon fill="none" stroke="#29235c" points="1965.57,-1737.37 1965.57,-1797.37 2570.57,-1797.37 2570.57,-1737.37 1965.57,-1737.37"/>
<text text-anchor="start" x="1976.57" y="-1758.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">id</text>
<text text-anchor="start" x="2001.46" y="-1758.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> &#160;&#160;&#160;</text>
<text text-anchor="start" x="2468.9" y="-1758.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2520.48" y="-1758.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2529.37" y="-1758.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1965.57,-1677.37 1965.57,-1737.37 2570.57,-1737.37 2570.57,-1677.37 1965.57,-1677.37"/>
<polygon fill="none" stroke="#29235c" points="1965.57,-1677.37 1965.57,-1737.37 2570.57,-1737.37 2570.57,-1677.37 1965.57,-1677.37"/>
<text text-anchor="start" x="1976.57" y="-1697.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">user_id &#160;&#160;&#160;</text>
<text text-anchor="start" x="2468.9" y="-1698.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">text</text>
<text text-anchor="start" x="2520.48" y="-1698.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2529.37" y="-1698.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="#e7e2dd" stroke="transparent" points="1965.57,-1617.37 1965.57,-1677.37 2570.57,-1677.37 2570.57,-1617.37 1965.57,-1617.37"/>
<polygon fill="none" stroke="#29235c" points="1965.57,-1617.37 1965.57,-1677.37 2570.57,-1677.37 2570.57,-1617.37 1965.57,-1617.37"/>
<text text-anchor="start" x="1976.49" y="-1637.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">expires_at &#160;&#160;&#160;</text>
<text text-anchor="start" x="2159.56" y="-1638.57" font-family="Helvetica,sans-Serif" font-style="italic" font-size="32.00" fill="#29235c">timestamp with time zone</text>
<text text-anchor="start" x="2520.48" y="-1638.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c"> </text>
<text text-anchor="start" x="2529.37" y="-1638.57" font-family="Helvetica,sans-Serif" font-weight="bold" font-size="32.00" fill="#29235c">(!)</text>
<polygon fill="none" stroke="#29235c" stroke-width="2" points="1964.07,-1616.37 1964.07,-1858.37 2571.07,-1858.37 2571.07,-1616.37 1964.07,-1616.37"/>
</g>
<!-- session&#45;&gt;user -->
<!-- session&#45;&gt;user -->
<g id="edge32" class="edge">
<title>session:e&#45;&gt;user:w</title>
<path fill="none" stroke="#29235c" stroke-width="3" d="M2571.57,-1707.37C2682.92,-1707.37 2671,-1812.88 2706.68,-1918.37 2754.2,-2058.88 2671.6,-3225.23 2809.61,-3277.55"/>
<polygon fill="#29235c" stroke="#29235c" stroke-width="3" points="2809.01,-3281 2819.48,-3279.37 2810.28,-3274.12 2809.01,-3281"/>
<text text-anchor="middle" x="2810.59" y="-3250.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">1</text>
<text text-anchor="middle" x="2577.79" y="-1678.57" font-family="Helvetica,sans-Serif" font-size="32.00" fill="#29235c">*</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -3,7 +3,7 @@
## Development Timeline & Activity Log
**Project Start:** 26 September 2025
**Total Commits:** 99 commits
**Total Commits:** 125 commits
**Primary Developer:** Zias van Nes
**Tech Stack:** SvelteKit, Drizzle ORM, PostgreSQL, Cloudflare R2, MapLibre GL JS
@@ -11,6 +11,208 @@
## December 2025
### 16 December 2025 - 8 uren
**Werk uitgevoerd:**
- **Phase 7: Docker Deployment & Map Improvements**
- Self-hosted Docker deployment configuratie voltooid
- Custom OSM map styles geïmplementeerd (dark-matter, positron)
- Rating systeem voor finds toegevoegd
- Database ERD gegenereerd
- Location names migratie van finds naar location table
- Location markers op kaart toegevoegd
- CSP fixes voor OSM tile servers
- Production-ready Docker setup met automated migrations
**Commits:**
- 4af0e3d - fix:location names
- f48746c - fix:csp of osm styles
- 200c761 - fix:OSM
- 20b5674 - fix:switch back to OSM
- 42670d1 - feat:db erd
- b6b7319 - fix:api-sync rating and CORS new OSM tiles
- 95ddd10 - feat:custom map styles
- 851a9df - feat:rating
- abed279 - fix:more bg-opacity to POI search to increase visibility
- 5d45ec7 - fix:location name table in [findid] api
- 1a7703b - fix:add drizzle to prod instead of dev so migrations can be ran in build step
- b7eb7ad - fix:remove loader skeleton from main page when not logged in
- 81645a4 - fix:drizzle is needed to perform migrations in the build step
- deebeb0 - add:db migration to dockerbuild and edit origin url
- 0c1c9d2 - fix:docker
- ae6a96d - feat:use selfhosted docker
- 577a3ca - feat:migrate location name from finds to location table
**Details:**
**Docker Deployment System (ae6a96d, 0c1c9d2, deebeb0, 1a7703b, 81645a4):**
- Complete self-hosted Docker setup geïmplementeerd
- Automated database migrations in Docker entrypoint
- Production-ready container configuration
- Origin URL configuratie voor deployment
- Drizzle ORM in production dependencies voor migration support
- Docker build optimalisaties voor snellere deployments
- Environment variable handling in containerized environment
**Rating System Implementation (851a9df, b6b7319):**
- Rating functionaliteit toegevoegd aan finds
- API-sync layer uitgebreid voor rating state management
- User ratings met real-time updates
- Database schema uitbreiding voor ratings storage
- Rating UI components geïntegreerd in FindCard
- CORS fixes voor nieuwe functionality
**Custom Map Styles (95ddd10, 20b5674, 200c761, f48746c):**
- Custom OSM map styles: dark-matter en positron
- Map style JSON configuratie files toegevoegd (static/map-styles/)
- Switched to OpenStreetMap tiles voor betere customizability
- CSP (Content Security Policy) updates voor OSM tile servers
- Style switcher functionaliteit voorbereid
- Betere visuele consistentie met app theme
**Location Architecture Refactor (577a3ca, 5d45ec7, 4af0e3d):**
- Grote database refactor: location names gemigreerd van finds naar location table
- Multiple finds kunnen nu dezelfde location delen
- Location-based find grouping geïmplementeerd
- Frontend components volledig geüpdatet voor nieuwe architectuur
- Location name handling verbeterd in API endpoints
- Better data normalization en reduced redundancy
**Map Enhancements (d67b9b7, e79d574, 92457f9, abed279):**
- Location markers toegevoegd aan kaart
- Overflow fixes in location list component
- POI search visibility verbeteringen (background opacity)
- Styling verbeteringen voor betere UX
- Enhanced marker positioning en clustering
**Database & Documentation (42670d1):**
- Database ERD (Entity Relationship Diagram) gegenereerd
- Visual documentation van database schema
- erd.svg bestand toegevoegd aan repository root
- Improved technical documentation
**UI/UX Polish (b7eb7ad):**
- Loading skeleton verwijderd van main page voor niet-ingelogde gebruikers
- Betere first-time user experience
- Cleaner landing page presentatie
**Technical Implementation:**
- **Docker Setup:**
- Multi-stage Docker build voor optimale image size
- Health checks geïmplementeerd
- Automated migration runner in entrypoint script
- Production-optimized configurations
- Environment-based configuration management
- **Rating System:**
- Star-based rating interface (1-5 stars)
- Real-time rating aggregation
- User rating history tracking
- Optimistic UI updates via api-sync
- Database indexes voor performance
- **Location Architecture:**
- Normalized location data (separate table)
- Find-location relationships via foreign keys
- Location reusability across multiple finds
- Improved query performance with proper indexing
- Frontend refactored voor nieuwe data structure
- **Map Styles:**
- Two custom OSM-based styles (dark/light)
- MapLibre GL JS style specifications
- Tile server configuration voor self-hosting readiness
- CSP-compliant external resource loading
**User Experience Improvements:**
- Cleaner landing page voor nieuwe gebruikers
- Betere map visibility met custom styles
- Rating functionality voor community feedback
- Location-based find organization
- Production-ready deployment voor stable hosting
---
### 8-15 December 2025 - 12 uren
**Werk uitgevoerd:**
- **Phase 6: Major Logic Overhaul - Locations & Finds Refactor**
- Complete applicatie-architectuur herziening
- Location-based find system geïmplementeerd
- Multiple finds per location ondersteuning
- Database schema volledig gerefactored
- Location markers en UI verbeteringen
- Overflow fixes in location lists
**Commits:**
- 2122511 - Merge pull request 'logic-overhaul' (#4) from logic-overhaul into main
- 2e14a2f - fix
- 61ffd2d - let the fun begin!
- 495e67f - feat:use locations&finds
- d67b9b7 - add:location marker
- e79d574 - fix:overflow in location list
- 92457f9 - fix: some styles
**Details:**
**Major Architecture Overhaul (495e67f, 61ffd2d, 2e14a2f):**
- Complete redesign van data model: locations en finds gescheiden
- Location-centric architecture waarbij multiple finds aan dezelfde locatie gekoppeld kunnen worden
- Users kunnen nu dezelfde plaats registreren met verschillende finds
- Database schema volledig geherstructureerd met nieuwe relationships
- Migration scripts voor data transformatie van oude naar nieuwe structuur
- API endpoints volledig aangepast voor nieuwe architectuur
- Frontend components gerefactored voor location-based navigation
**Location System Implementation:**
- Nieuwe locations table als centrale entiteit
- Finds worden gegroepeerd per location
- Location sharing tussen users mogelijk
- Geoptimaliseerde queries voor location-based find retrieval
- Enhanced location search en filtering
- Location-based map clustering
**UI & Navigation Updates (d67b9b7, e79d574, 92457f9):**
- Location markers toegevoegd aan kaart interface
- Location list overflow issues opgelost
- Styling verbeteringen voor location cards
- Better responsive design voor location views
- Improved navigation tussen locations en finds
**Technical Details:**
- Backward compatibility behouden tijdens migratie
- Data integrity checks geïmplementeerd
- Transaction-safe migrations
- Type-safe nieuwe interfaces across frontend/backend
- Comprehensive testing van nieuwe logic flow
**Impact:**
- Enables collaborative location mapping (multiple users, same place)
- Better data organization en reduced redundancy
- Improved scalability voor location-based features
- Foundation voor future features (location ratings, check-ins, etc.)
- More intuitive user experience voor place discovery
---
### 1 December 2025 - 4 uren
**Werk uitgevoerd:**
@@ -708,13 +910,13 @@
## Totaal Overzicht
**Totale geschatte uren:** 110 uren
**Totaal aantal commits:** 99 commits
**Totale geschatte uren:** 130 uren
**Totaal aantal commits:** 125 commits
### Git Statistics:
```
Total Commits: 99
Total Commits: 125
Primary Author: Zias van Nes
Commit Breakdown by Phase:
- Initial Setup & Auth (Sept 26-27): 16 commits
@@ -727,6 +929,8 @@ Commit Breakdown by Phase:
- Polish & Refinement (Nov 4-8): 9 commits
- Major Overhauls (Nov 17-23): 9 commits
- Find Management (Dec 1): 2 commits
- Logic Overhaul (Dec 8-15): 7 commits
- Docker & Map Improvements (Dec 16): 17 commits
```
### Project Milestones:
@@ -750,6 +954,10 @@ Commit Breakdown by Phase:
**Phase 6: Find Management & Advanced Features (Dec 1)** 46. Complete find update functionality 47. Find delete with media cleanup 48. EditFindModal component met media management 49. Individual media deletion 50. API-sync layer enhancement voor optimistic updates 51. Automatic database synchronization 52. Rollback mechanism voor failed operations
**Phase 7: Major Logic Overhaul - Locations & Finds (Dec 8-15)** 53. Complete architecture redesign: locations and finds separation 54. Location-centric system: multiple finds per location 55. Database schema complete refactor met nieuwe relationships 56. Location sharing tussen users geïmplementeerd 57. Location markers en map clustering 58. UI overhaul voor location-based navigation 59. Data migration van oude naar nieuwe structuur 60. Enhanced location search en filtering
**Phase 8: Docker Deployment & Map Improvements (Dec 16)** 61. Self-hosted Docker deployment configuratie 62. Automated database migrations in Docker entrypoint 63. Production-ready container setup 64. Rating system voor finds geïmplementeerd 65. Custom OSM map styles (dark-matter, positron) 66. Location names migratie naar location table 67. Database ERD generation voor documentation 68. CSP fixes voor OSM tile servers 69. API-sync rating integration met real-time updates 70. Map style switching infrastructure
### Hoofdfunctionaliteiten geïmplementeerd:
**Authentication & Users:**
@@ -778,6 +986,10 @@ Commit Breakdown by Phase:
- [x] Google Maps Places API integratie
- [x] POI search functionaliteit
- [x] Enhanced marker positioning
- [x] Custom OSM map styles (dark-matter, positron)
- [x] Location markers op kaart
- [x] Location-based find clustering
- [x] CSP-compliant tile loading
**Finds & Media:**
@@ -795,6 +1007,9 @@ Commit Breakdown by Phase:
- [x] Individual media deletion
- [x] Optimistic updates met automatic sync
- [x] Find deletion met authorization checks
- [x] Rating system voor finds (1-5 stars)
- [x] Location-based find organization
- [x] Multiple finds per location support
**Social Interactions:**
@@ -831,6 +1046,8 @@ Commit Breakdown by Phase:
- [x] PostgreSQL database (Drizzle ORM)
- [x] Docker deployment setup
- [x] Vercel production deployment
- [x] Self-hosted Docker deployment met automated migrations
- [x] Production-ready container configuration
- [x] API architectuur met dedicated routes
- [x] Sync-service voor data synchronisatie
- [x] API-sync layer voor optimistic updates
@@ -838,6 +1055,9 @@ Commit Breakdown by Phase:
- [x] Type-safe interfaces across entire stack
- [x] Automatic rollback mechanism
- [x] Centralized state management
- [x] Database ERD documentation
- [x] Location-centric architecture
- [x] Normalized data relationships
**Developer Experience:**
@@ -880,10 +1100,12 @@ src/lib/components/
- Users table (auth, profiles)
- Sessions table (Lucia auth)
- OAuth accounts table
- Finds table (posts met location en media)
- Locations table (shared location data)
- Finds table (posts at locations met media)
- Likes table (user interactions)
- Comments table (nested discussions)
- Friendships table (social connections)
- Ratings table (find ratings)
- Notification subscriptions table
- Notification preferences table
@@ -973,6 +1195,10 @@ src/lib/components/
6. **Mobile UX:** Unified interface eliminates duplication en improves consistency
7. **Optimistic Updates:** API-sync layer met automatic rollback voor seamless UX
8. **State Management:** Centralized sync architecture eliminates redundant code
9. **Data Architecture:** Complete overhaul naar location-centric model voor better scalability
10. **Docker Deployment:** Automated migrations in container entrypoint voor production reliability
11. **Map Customization:** Custom OSM styles met proper CSP configuration
12. **Data Normalization:** Location names migratie reduces redundancy en improves data integrity
### Future Considerations:
@@ -988,6 +1214,11 @@ src/lib/components/
- [ ] Export/backup functionaliteit
- [ ] Batch operations (multi-select delete/edit)
- [ ] Media reordering in finds
- [ ] Map style switcher UI
- [ ] Self-hosted tile server
- [ ] Location-based recommendations
- [ ] User check-ins at locations
- [ ] Location popularity metrics
---
@@ -1077,6 +1308,6 @@ pnpm run format # Prettier --write
---
**Last Updated:** 1 December 2025
**Last Updated:** 16 December 2025
**Status:** Active Development
**Version:** Beta (Pre-release)

View File

@@ -16,7 +16,8 @@
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"db:generate-erd": "drizzle-erd --in ./src/lib/server/db/schema.ts --out erd.svg"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
@@ -25,14 +26,15 @@
"@lucide/svelte": "^0.544.0",
"@oslojs/crypto": "^1.0.1",
"@oslojs/encoding": "^1.1.0",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.1.13",
"@types/node": "^22",
"bits-ui": "^2.11.4",
"clsx": "^2.1.1",
"drizzle-erd": "0.0.1-alpha.11",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.40.0",
"eslint": "^9.22.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-storybook": "^9.1.8",
@@ -59,6 +61,7 @@
"@node-rs/argon2": "^2.0.2",
"@sveltejs/adapter-vercel": "^5.10.2",
"arctic": "^3.7.0",
"drizzle-orm": "^0.40.0",
"lucide-svelte": "^0.553.0",
"nanoid": "^5.1.6",
"postgres": "^3.4.5",

View File

@@ -13,9 +13,9 @@ export const handle: Handle = async ({ event, resolve }) => {
!origin ||
origin.includes('localhost') ||
origin.includes('127.0.0.1') ||
origin.includes('serengo.ziasvannes.tech')
origin.includes('serengo.zias.be')
) {
// Allow in development and serengo.ziasvannes.tech
// Allow in development and serengo.zias.be
}
// In production, you would add: else if (origin !== 'yourdomain.com') { return new Response('Forbidden', { status: 403 }); }
}
@@ -50,9 +50,9 @@ export const handle: Handle = async ({ event, resolve }) => {
"worker-src 'self' blob:; " +
"style-src 'self' 'unsafe-inline' fonts.googleapis.com; " +
"font-src 'self' fonts.gstatic.com; " +
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.r2.cloudflarestorage.com *.r2.dev; " +
"img-src 'self' data: blob: *.openstreetmap.org *.tile.openstreetmap.org *.basemaps.cartocdn.com *.r2.cloudflarestorage.com *.r2.dev; " +
"media-src 'self' *.r2.cloudflarestorage.com *.r2.dev; " +
"connect-src 'self' *.openstreetmap.org https://fcm.googleapis.com https://android.googleapis.com; " +
"connect-src 'self' *.openstreetmap.org *.basemaps.cartocdn.com https://fcm.googleapis.com https://android.googleapis.com; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self';"

View File

@@ -2,7 +2,6 @@
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
import { resolveRoute } from '$app/paths';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
@@ -59,7 +58,7 @@
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : resolveRoute(href)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}

View File

@@ -2,29 +2,23 @@
import { Input } from '$lib/components/input';
import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button';
import { coordinates } from '$lib/stores/location';
import POISearch from '../map/POISearch.svelte';
import type { PlaceResult } from '$lib/utils/places';
interface Props {
isOpen: boolean;
locationId: string;
onClose: () => void;
onFindCreated: (event: CustomEvent) => void;
}
let { isOpen, onClose, onFindCreated }: Props = $props();
let { isOpen, locationId, onClose, onFindCreated }: Props = $props();
let title = $state('');
let description = $state('');
let latitude = $state('');
let longitude = $state('');
let locationName = $state('');
let category = $state('cafe');
let isPublic = $state(true);
let selectedFiles = $state<FileList | null>(null);
let isSubmitting = $state(false);
let uploadedMedia = $state<Array<{ type: string; url: string; thumbnailUrl: string }>>([]);
let useManualLocation = $state(false);
const categories = [
{ value: 'cafe', label: 'Café' },
@@ -51,13 +45,6 @@
return () => window.removeEventListener('resize', checkIsMobile);
});
$effect(() => {
if (isOpen && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
});
function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
selectedFiles = target.files;
@@ -85,10 +72,7 @@
}
async function handleSubmit() {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!title.trim() || isNaN(lat) || isNaN(lng)) {
if (!title.trim()) {
return;
}
@@ -105,11 +89,9 @@
'Content-Type': 'application/json'
},
body: JSON.stringify({
locationId,
title: title.trim(),
description: description.trim() || null,
latitude: lat,
longitude: lng,
locationName: locationName.trim() || null,
category,
isPublic,
media: uploadedMedia
@@ -131,31 +113,13 @@
}
}
function handlePlaceSelected(place: PlaceResult) {
locationName = place.name;
latitude = place.latitude.toString();
longitude = place.longitude.toString();
}
function toggleLocationMode() {
useManualLocation = !useManualLocation;
if (!useManualLocation && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
}
function resetForm() {
title = '';
description = '';
locationName = '';
latitude = '';
longitude = '';
category = 'cafe';
isPublic = true;
selectedFiles = null;
uploadedMedia = [];
useManualLocation = false;
const fileInput = document.querySelector('#media-files') as HTMLInputElement;
if (fileInput) {
@@ -210,33 +174,6 @@
></textarea>
</div>
<div class="location-section">
<div class="location-header">
<Label>Location</Label>
<button type="button" onclick={toggleLocationMode} class="toggle-button">
{useManualLocation ? 'Use Search' : 'Manual Entry'}
</button>
</div>
{#if useManualLocation}
<div class="field">
<Label for="location-name">Location name</Label>
<Input
name="location-name"
placeholder="Café Central, Brussels"
bind:value={locationName}
/>
</div>
{:else}
<POISearch
onPlaceSelected={handlePlaceSelected}
placeholder="Search for cafés, restaurants, landmarks..."
label=""
showNearbyButton={true}
/>
{/if}
</div>
<div class="field-group">
<div class="field">
<Label for="category">Category</Label>
@@ -307,34 +244,6 @@
</div>
{/if}
</div>
{#if useManualLocation || (!latitude && !longitude)}
<div class="field-group">
<div class="field">
<Label for="latitude">Latitude</Label>
<Input name="latitude" type="text" required bind:value={latitude} />
</div>
<div class="field">
<Label for="longitude">Longitude</Label>
<Input name="longitude" type="text" required bind:value={longitude} />
</div>
</div>
{:else if latitude && longitude}
<div class="coordinates-display">
<Label>Selected coordinates</Label>
<div class="coordinates-info">
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
<button
type="button"
onclick={() => (useManualLocation = true)}
class="edit-coords-button"
>
Edit
</button>
</div>
</div>
{/if}
</div>
<div class="modal-footer">
@@ -358,7 +267,6 @@
width: 40%;
max-width: 600px;
min-width: 500px;
height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
@@ -615,76 +523,6 @@
flex: 1;
}
.location-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.location-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-button {
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
height: auto;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.toggle-button:hover {
background: hsl(var(--secondary) / 0.8);
}
.coordinates-display {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.coordinates-info {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 8px;
font-size: 0.875rem;
}
.coordinate {
color: hsl(var(--muted-foreground));
font-family: monospace;
font-size: 0.8125rem;
}
.edit-coords-button {
margin-left: auto;
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
height: auto;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.edit-coords-button:hover {
background: hsl(var(--secondary) / 0.8);
}
/* Mobile specific adjustments */
@media (max-width: 767px) {
.modal-header {

View File

@@ -459,7 +459,6 @@
width: 40%;
max-width: 600px;
min-width: 500px;
height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);

View File

@@ -12,6 +12,7 @@
import VideoPlayer from '../media/VideoPlayer.svelte';
import ProfilePicture from '../profile/ProfilePicture.svelte';
import CommentsList from './CommentsList.svelte';
import Rating from './Rating.svelte';
import { Ellipsis, MessageCircle, Share, Edit, Trash2 } from '@lucide/svelte';
import { apiSync } from '$lib/stores/api-sync';
@@ -39,6 +40,9 @@
likeCount?: number;
isLiked?: boolean;
commentCount?: number;
rating?: number | null;
ratingCount?: number;
userRating?: number | null;
currentUserId?: string;
onExplore?: (id: string) => void;
onDeleted?: () => void;
@@ -61,6 +65,9 @@
likeCount = 0,
isLiked = false,
commentCount = 0,
rating,
ratingCount = 0,
userRating,
currentUserId,
onExplore,
onDeleted,
@@ -237,6 +244,18 @@
</Button>
</div>
<!-- Rating Section -->
{#if currentUserId}
<div class="rating-section">
<Rating
findId={id}
initialRating={rating ? rating / 100 : 0}
initialCount={ratingCount}
{userRating}
/>
</div>
{/if}
<!-- Comments Section -->
{#if showComments}
<div class="comments-section">
@@ -254,6 +273,7 @@
<style>
.find-card {
backdrop-filter: blur(10px);
margin-top: 1rem;
margin-bottom: 1rem;
}
@@ -426,6 +446,13 @@
font-size: 0.875rem;
}
/* Rating Section */
.rating-section {
padding: 0.75rem 1rem;
border-top: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.2);
}
/* Comments Section */
.comments-section {
padding: 0 1rem 1rem 1rem;

View File

@@ -0,0 +1,156 @@
<script lang="ts">
import { Star } from 'lucide-svelte';
interface Props {
findId: string;
initialRating?: number;
initialCount?: number;
userRating?: number | null;
onRatingChange?: (rating: number) => void;
readonly?: boolean;
}
let {
findId,
initialRating = 0,
initialCount = 0,
userRating = null,
onRatingChange,
readonly = false
}: Props = $props();
let rating = $state(initialRating);
let ratingCount = $state(initialCount);
let currentUserRating = $state(userRating);
let hoverRating = $state(0);
let isSubmitting = $state(false);
async function handleRate(stars: number) {
if (readonly || isSubmitting) return;
isSubmitting = true;
try {
const response = await fetch(`/api/finds/${findId}/rate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ rating: stars })
});
if (!response.ok) {
throw new Error('Failed to rate find');
}
const data = await response.json();
rating = data.rating / 100;
ratingCount = data.ratingCount;
currentUserRating = stars;
if (onRatingChange) {
onRatingChange(stars);
}
} catch (error) {
console.error('Error rating find:', error);
} finally {
isSubmitting = false;
}
}
function getDisplayRating() {
if (readonly) {
return rating;
}
return hoverRating || rating;
}
</script>
<div class="rating-container">
<div class="stars">
{#each [1, 2, 3, 4, 5] as star}
<button
class="star-button"
class:filled={star <= getDisplayRating()}
class:user-rated={!readonly && currentUserRating && star <= currentUserRating}
class:readonly
disabled={readonly || isSubmitting}
onclick={() => handleRate(star)}
onmouseenter={() => !readonly && (hoverRating = star)}
onmouseleave={() => !readonly && (hoverRating = 0)}
aria-label={`Rate ${star} star${star > 1 ? 's' : ''}`}
>
<Star
class="star-icon"
fill={star <= getDisplayRating() ? 'currentColor' : 'none'}
size={readonly ? 16 : 24}
/>
</button>
{/each}
</div>
{#if ratingCount > 0}
<span class="rating-info">
{(rating || 0).toFixed(1)} ({ratingCount}
{ratingCount === 1 ? 'rating' : 'ratings'})
</span>
{:else if !readonly}
<span class="rating-info">Be the first to rate</span>
{/if}
</div>
<style>
.rating-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.stars {
display: flex;
gap: 0.25rem;
}
.star-button {
background: none;
border: none;
padding: 0.25rem;
cursor: pointer;
color: #94a3b8;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.star-button:not(.readonly):hover {
transform: scale(1.1);
color: #fbbf24;
}
.star-button.filled {
color: #fbbf24;
}
.star-button.user-rated {
color: #f59e0b;
}
.star-button.readonly {
cursor: default;
}
.star-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.rating-info {
font-size: 0.875rem;
color: #64748b;
white-space: nowrap;
}
:global(.star-icon) {
stroke-width: 2;
}
</style>

View File

@@ -9,3 +9,4 @@ export { default as FindPreview } from './FindPreview.svelte';
export { default as FindsFilter } from './FindsFilter.svelte';
export { default as FindsList } from './FindsList.svelte';
export { default as LikeButton } from './LikeButton.svelte';
export { default as Rating } from './Rating.svelte';

View File

@@ -0,0 +1,457 @@
<script lang="ts">
import { Input } from '$lib/components/input';
import { Label } from '$lib/components/label';
import { Button } from '$lib/components/button';
import { coordinates } from '$lib/stores/location';
import POISearch from '../map/POISearch.svelte';
import type { PlaceResult } from '$lib/utils/places';
interface Props {
isOpen: boolean;
onClose: () => void;
onLocationCreated: (event: CustomEvent<{ locationId: string; reload?: boolean }>) => void;
}
let { isOpen, onClose, onLocationCreated }: Props = $props();
let latitude = $state('');
let longitude = $state('');
let locationName = $state('');
let isSubmitting = $state(false);
let useManualLocation = $state(false);
let isMobile = $state(false);
$effect(() => {
if (typeof window === 'undefined') return;
const checkIsMobile = () => {
isMobile = window.innerWidth < 768;
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
});
$effect(() => {
if (isOpen && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
});
async function handleSubmit() {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (isNaN(lat) || isNaN(lng)) {
return;
}
isSubmitting = true;
try {
const response = await fetch('/api/locations', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
latitude: lat,
longitude: lng,
locationName: locationName.trim() || null
})
});
if (!response.ok) {
throw new Error('Failed to create location');
}
const result = await response.json();
resetForm();
onLocationCreated(
new CustomEvent('locationCreated', {
detail: { locationId: result.location.id, reload: true }
})
);
onClose();
} catch (error) {
console.error('Error creating location:', error);
alert('Failed to create location. Please try again.');
} finally {
isSubmitting = false;
}
}
function handlePlaceSelected(place: PlaceResult) {
locationName = place.name;
latitude = place.latitude.toString();
longitude = place.longitude.toString();
}
function toggleLocationMode() {
useManualLocation = !useManualLocation;
if (!useManualLocation && $coordinates) {
latitude = $coordinates.latitude.toString();
longitude = $coordinates.longitude.toString();
}
}
function resetForm() {
latitude = '';
longitude = '';
locationName = '';
useManualLocation = false;
}
function closeModal() {
resetForm();
onClose();
}
</script>
{#if isOpen}
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<h2 class="modal-title">Create Location</h2>
<button type="button" class="close-button" onclick={closeModal} aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="form"
>
<div class="modal-body">
<p class="description">
Choose a location where you and others can create finds (posts). This will be a point on
the map where discoveries can be shared.
</p>
<div class="location-section">
<div class="location-header">
<Label>Location</Label>
<button type="button" onclick={toggleLocationMode} class="toggle-button">
{useManualLocation ? 'Use Search' : 'Manual Entry'}
</button>
</div>
{#if !useManualLocation}
<POISearch
onPlaceSelected={handlePlaceSelected}
placeholder="Search for a place..."
label=""
showNearbyButton={true}
/>
{/if}
</div>
<div class="field">
<Label for="location-name">Location Name (Optional)</Label>
<Input
name="location-name"
type="text"
placeholder="Café Central, Brussels"
bind:value={locationName}
/>
</div>
{#if useManualLocation || (!latitude && !longitude)}
<div class="field-group">
<div class="field">
<Label for="latitude">Latitude</Label>
<Input name="latitude" type="text" required bind:value={latitude} />
</div>
<div class="field">
<Label for="longitude">Longitude</Label>
<Input name="longitude" type="text" required bind:value={longitude} />
</div>
</div>
{:else if latitude && longitude}
<div class="coordinates-display">
<Label>Selected coordinates</Label>
<div class="coordinates-info">
<span class="coordinate">Lat: {parseFloat(latitude).toFixed(6)}</span>
<span class="coordinate">Lng: {parseFloat(longitude).toFixed(6)}</span>
<button
type="button"
onclick={() => (useManualLocation = true)}
class="edit-coords-button"
>
Edit
</button>
</div>
</div>
{/if}
</div>
<div class="modal-footer">
<Button variant="ghost" type="button" onclick={closeModal} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !latitude || !longitude}>
{isSubmitting ? 'Creating...' : 'Create Location'}
</Button>
</div>
</form>
</div>
</div>
{/if}
<style>
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: 40%;
max-width: 600px;
min-width: 500px;
max-height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.modal-container.mobile {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 0;
max-width: none;
max-height: 90vh;
border-radius: 16px 16px 0 0;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-content {
display: flex;
flex-direction: column;
height: 100%;
background: rgba(255, 255, 255, 0.6);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.modal-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.close-button:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.form {
height: 100%;
display: flex;
flex-direction: column;
}
.modal-body {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
}
.description {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0;
line-height: 1.5;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.field-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.modal-footer {
display: flex;
gap: 0.75rem;
padding: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.modal-footer :global(button) {
flex: 1;
}
.location-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.location-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-button {
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
height: auto;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.toggle-button:hover {
background: hsl(var(--secondary) / 0.8);
}
.coordinates-display {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.coordinates-info {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: hsl(var(--muted) / 0.5);
border: 1px solid hsl(var(--border));
border-radius: 8px;
font-size: 0.875rem;
}
.coordinate {
color: hsl(var(--muted-foreground));
font-family: monospace;
font-size: 0.8125rem;
}
.edit-coords-button {
margin-left: auto;
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
height: auto;
background: hsl(var(--secondary));
border: 1px solid hsl(var(--border));
border-radius: 6px;
color: hsl(var(--secondary-foreground));
cursor: pointer;
transition: all 0.2s ease;
font-weight: 500;
}
.edit-coords-button:hover {
background: hsl(var(--secondary) / 0.8);
}
/* Mobile specific adjustments */
@media (max-width: 767px) {
.modal-header {
padding: 1rem;
}
.modal-title {
font-size: 1.25rem;
}
.modal-body {
padding: 1rem;
gap: 1.25rem;
}
.modal-footer {
padding: 1rem;
gap: 0.5rem;
}
.field-group {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,257 @@
<script lang="ts">
import { formatDistance } from '$lib/utils/distance';
import { Star } from 'lucide-svelte';
interface Location {
id: string;
latitude: string;
longitude: string;
locationName?: string | null;
createdAt: string;
userId: string;
username: string;
profilePictureUrl?: string | null;
findCount: number;
averageRating?: number | null;
ratingCount?: number;
distance?: number;
}
interface Props {
location: Location;
onExplore?: (id: string) => void;
}
let { location, onExplore }: Props = $props();
function handleExplore() {
onExplore?.(location.id);
}
</script>
<article class="location-card">
<div class="location-info">
<div class="location-header">
<div class="location-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" class="location-icon">
<path
d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2" />
</svg>
<div>
<h3 class="title">
{#if location.locationName}
{location.locationName}
{:else if location.distance !== undefined}
{formatDistance(location.distance)} away
{:else}
Location
{/if}
</h3>
<p class="coordinates">
{parseFloat(location.latitude).toFixed(4)}, {parseFloat(location.longitude).toFixed(4)}
</p>
</div>
</div>
{#if location.distance !== undefined}
<div class="distance-badge">{formatDistance(location.distance)}</div>
{/if}
</div>
<div class="location-meta">
<div class="meta-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="8" r="4" stroke="currentColor" stroke-width="2" />
<path
d="M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<span>Created by {location.username}</span>
</div>
<div class="meta-item">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>{location.findCount} {location.findCount === 1 ? 'find' : 'finds'}</span>
</div>
{#if location.averageRating && (location.ratingCount || 0) > 0}
<div class="meta-item rating">
<Star size={16} fill="currentColor" class="star-icon" />
<span>{(location.averageRating / 100).toFixed(1)} ({location.ratingCount})</span>
</div>
{/if}
</div>
</div>
<button type="button" class="explore-button" onclick={handleExplore}>
<span>Explore</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M5 12h14M12 5l7 7-7 7"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</article>
<style>
.location-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 12px;
transition: all 0.2s ease;
gap: 1rem;
}
.location-card:hover {
border-color: hsl(var(--primary) / 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.location-info {
flex: 1;
min-width: 0;
}
.location-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
gap: 1rem;
}
.location-title {
display: flex;
align-items: flex-start;
gap: 0.75rem;
min-width: 0;
}
.location-icon {
color: hsl(var(--primary));
flex-shrink: 0;
margin-top: 2px;
}
.title {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
color: hsl(var(--foreground));
line-height: 1.3;
}
.coordinates {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin: 0;
font-family: monospace;
}
.distance-badge {
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.location-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
}
.meta-item svg {
flex-shrink: 0;
}
.meta-item.rating {
color: #fbbf24;
}
:global(.star-icon) {
stroke-width: 2;
}
.explore-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
flex-shrink: 0;
}
.explore-button:hover {
background: hsl(var(--primary) / 0.9);
transform: translateX(2px);
}
.explore-button svg {
transition: transform 0.2s ease;
}
.explore-button:hover svg {
transform: translateX(2px);
}
@media (max-width: 640px) {
.location-card {
flex-direction: column;
align-items: stretch;
}
.explore-button {
width: 100%;
justify-content: center;
}
.distance-badge {
display: none;
}
}
</style>

View File

@@ -0,0 +1,391 @@
<script lang="ts">
import FindsList from '../finds/FindsList.svelte';
import { Button } from '$lib/components/button';
import { goto } from '$app/navigation';
import EditFindModal from '../finds/EditFindModal.svelte';
interface Find {
id: string;
locationId: string;
title: string;
description?: string;
category?: string;
locationName?: string;
isPublic: number;
userId: string;
username: string;
profilePictureUrl?: string | null;
likeCount?: number;
isLikedByUser?: boolean;
isFromFriend?: boolean;
latitude?: string;
longitude?: string;
media?: Array<{
id: string;
type: string;
url: string;
thumbnailUrl: string;
orderIndex?: number | null;
}>;
}
interface Location {
id: string;
latitude: string;
longitude: string;
createdAt: string;
userId: string;
username: string;
findCount: number;
finds?: Find[];
}
interface Props {
isOpen: boolean;
location: Location | null;
currentUserId?: string;
onClose: () => void;
onCreateFind?: () => void;
}
let { isOpen, location, currentUserId, onClose, onCreateFind }: Props = $props();
let isMobile = $state(false);
let showEditModal = $state(false);
let findToEdit = $state<Find | null>(null);
$effect(() => {
if (typeof window === 'undefined') return;
const checkIsMobile = () => {
isMobile = window.innerWidth < 768;
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
});
function handleCreateFind() {
onCreateFind?.();
}
function handleFindExplore(findId: string) {
goto(`/finds/${findId}`);
}
function handleFindEdit(findData: any) {
const find = location?.finds?.find((f) => f.id === findData.id);
if (find) {
findToEdit = find;
showEditModal = true;
}
}
function handleEditModalClose() {
showEditModal = false;
findToEdit = null;
}
function handleFindUpdated() {
showEditModal = false;
findToEdit = null;
window.location.reload();
}
function handleFindDeleted() {
showEditModal = false;
findToEdit = null;
window.location.reload();
}
</script>
{#if isOpen && location}
<div class="modal-container" class:mobile={isMobile}>
<div class="modal-content">
<div class="modal-header">
<div class="header-info">
<h2 class="modal-title">Location Finds</h2>
<p class="location-coords">
{parseFloat(location.latitude).toFixed(4)}, {parseFloat(location.longitude).toFixed(4)}
</p>
</div>
<button type="button" class="close-button" onclick={onClose} aria-label="Close">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M18 6L6 18M6 6L18 18"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<div class="modal-body">
{#if location.finds && location.finds.length > 0}
<FindsList
finds={location.finds.map((find) => ({
id: find.id,
locationId: find.locationId,
title: find.title,
description: find.description,
category: find.category,
locationName: find.locationName,
latitude: find.latitude,
longitude: find.longitude,
isPublic: find.isPublic,
userId: find.userId,
username: find.username,
profilePictureUrl: find.profilePictureUrl,
user: {
username: find.username,
profilePictureUrl: find.profilePictureUrl
},
likeCount: find.likeCount,
isLiked: find.isLikedByUser,
media: find.media
}))}
hideTitle={true}
{currentUserId}
onFindExplore={handleFindExplore}
onEdit={handleFindEdit}
/>
{:else}
<div class="empty-state">
<div class="empty-icon">
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<h3 class="empty-title">No finds yet</h3>
<p class="empty-message">Be the first to share a discovery at this location!</p>
</div>
{/if}
</div>
{#if currentUserId}
<div class="modal-footer">
<Button onclick={handleCreateFind} class="w-full">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
</svg>
Create Find Here
</Button>
</div>
{/if}
</div>
</div>
{/if}
{#if showEditModal && findToEdit}
<EditFindModal
isOpen={showEditModal}
find={{
id: findToEdit.id,
title: findToEdit.title,
description: findToEdit.description || null,
latitude: findToEdit.latitude || location?.latitude || '0',
longitude: findToEdit.longitude || location?.longitude || '0',
locationName: findToEdit.locationName || null,
category: findToEdit.category || null,
isPublic: findToEdit.isPublic ?? 1,
media: (findToEdit.media || []).map((m) => ({
id: m.id,
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || null,
orderIndex: m.orderIndex ?? null
}))
}}
onClose={handleEditModalClose}
onFindUpdated={handleFindUpdated}
onFindDeleted={handleFindDeleted}
/>
{/if}
<style>
.modal-container {
position: fixed;
top: 80px;
right: 20px;
width: 40%;
max-width: 600px;
min-width: 500px;
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 50;
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.modal-container.mobile {
top: auto;
bottom: 0;
left: 0;
right: 0;
width: 100%;
min-width: 0;
max-width: none;
height: 90vh;
border-radius: 16px 16px 0 0;
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-content {
display: flex;
flex-direction: column;
height: 100%;
background: rgba(255, 255, 255, 0.6);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.header-info {
flex: 1;
}
.modal-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0 0 0.25rem 0;
color: hsl(var(--foreground));
}
.location-coords {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
margin: 0;
font-family: monospace;
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
background: transparent;
color: hsl(var(--muted-foreground));
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.close-button:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.modal-body {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
height: 100%;
}
.empty-icon {
margin-bottom: 1.5rem;
color: hsl(var(--muted-foreground));
opacity: 0.4;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.75rem 0;
color: hsl(var(--foreground));
}
.empty-message {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0;
line-height: 1.5;
}
.modal-footer {
padding: 1.5rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
:global(.w-full) {
width: 100%;
}
:global(.mr-2) {
margin-right: 0.5rem;
}
@media (max-width: 767px) {
.modal-header {
padding: 1rem;
}
.modal-title {
font-size: 1.25rem;
}
.modal-footer {
padding: 1rem;
}
}
</style>

View File

@@ -0,0 +1,189 @@
<script lang="ts">
import LocationCard from './LocationCard.svelte';
interface Location {
id: string;
latitude: string;
longitude: string;
locationName?: string | null;
createdAt: string;
userId: string;
username: string;
profilePictureUrl?: string | null;
findCount: number;
distance?: number;
}
interface Props {
locations: Location[];
onLocationExplore?: (id: string) => void;
title?: string;
showEmpty?: boolean;
emptyMessage?: string;
hideTitle?: boolean;
}
let {
locations,
onLocationExplore,
title = 'Locations',
showEmpty = true,
emptyMessage = 'No locations nearby',
hideTitle = false
}: Props = $props();
function handleLocationExplore(id: string) {
onLocationExplore?.(id);
}
</script>
<section class="locations-feed">
{#if !hideTitle}
<div class="feed-header">
<h2 class="feed-title">{title}</h2>
</div>
{/if}
{#if locations.length > 0}
<div class="feed-container">
{#each locations as location (location.id)}
<LocationCard {location} onExplore={handleLocationExplore} />
{/each}
</div>
{:else if showEmpty}
<div class="empty-state">
<div class="empty-icon">
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
<h3 class="empty-title">No locations discovered yet</h3>
<p class="empty-message">{emptyMessage}</p>
<div class="empty-action">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
<span>Create a location to start sharing finds</span>
</div>
</div>
{/if}
</section>
<style>
.locations-feed {
width: 100%;
padding: 0 24px 24px 24px;
}
.feed-header {
margin-bottom: 1.5rem;
padding: 0 0.5rem;
}
.feed-title {
font-family: 'Washington', serif;
font-size: 1.875rem;
font-weight: 700;
margin: 0;
color: hsl(var(--foreground));
}
.feed-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
background: hsl(var(--card));
border-radius: 12px;
border: 1px solid hsl(var(--border));
}
.empty-icon {
margin-bottom: 1.5rem;
color: hsl(var(--muted-foreground));
opacity: 0.4;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.75rem 0;
color: hsl(var(--foreground));
}
.empty-message {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0 0 1.5rem 0;
line-height: 1.5;
}
.empty-action {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--primary));
font-size: 0.875rem;
font-weight: 500;
}
@media (max-width: 768px) {
.feed-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 1rem;
padding: 0;
}
.feed-title {
font-size: 1.5rem;
}
.empty-state {
padding: 3rem 1.5rem;
}
}
.feed-container {
scroll-behavior: smooth;
}
:global(.location-card) {
animation: fadeInUp 0.4s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
export { default as LocationCard } from './LocationCard.svelte';
export { default as LocationsList } from './LocationsList.svelte';
export { default as CreateLocationModal } from './CreateLocationModal.svelte';
export { default as SelectLocationModal } from './SelectLocationModal.svelte';
export { default as LocationFindsModal } from './LocationFindsModal.svelte';

View File

@@ -12,64 +12,59 @@
} from '$lib/stores/location';
import { Skeleton } from '$lib/components/skeleton';
interface Find {
interface Location {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: number;
createdAt: Date;
userId: string;
user: {
id: string;
username: string;
};
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
finds: Array<{
id: string;
title: string;
description?: string;
isPublic: number;
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
}>;
}
interface Props {
style?: StyleSpecification;
center?: [number, number];
zoom?: number;
class?: string;
autoCenter?: boolean;
finds?: Find[];
onFindClick?: (find: Find) => void;
locations?: Location[];
onLocationClick?: (location: Location) => void;
sidebarVisible?: boolean;
}
// Map styles - Positron for light mode, Dark Matter for dark mode
const LIGHT_STYLE = '/map-styles/positron.json';
const DARK_STYLE = '/map-styles/dark-matter.json';
// Detect dark mode preference
let isDarkMode = $state(false);
if (typeof window !== 'undefined') {
isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
// Compute map style based on dark mode preference
const mapStyle = $derived(isDarkMode ? DARK_STYLE : LIGHT_STYLE);
let {
style = {
version: 8,
sources: {
'osm-raster': {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors'
}
},
layers: [
{
id: 'osm-tiles',
type: 'raster',
source: 'osm-raster'
}
]
},
center,
zoom,
class: className = '',
autoCenter = true,
finds = [],
onFindClick,
locations = [],
onLocationClick,
sidebarVisible = false
}: Props = $props();
@@ -84,6 +79,21 @@
// Use a plain variable (not reactive) to track programmatic moves
let isProgrammaticMove = false;
// Listen for system theme changes
$effect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleThemeChange = (e: MediaQueryListEvent) => {
isDarkMode = e.matches;
};
mediaQuery.addEventListener('change', handleThemeChange);
return () => {
mediaQuery.removeEventListener('change', handleThemeChange);
};
});
// Calculate padding for map centering based on sidebar visibility
const getMapPadding = $derived.by(() => {
if (!sidebarVisible) {
@@ -246,7 +256,7 @@
<div class="map-wrapper" class:hidden={!mapReady}>
<MapLibre
{style}
style={mapStyle}
center={initialCenter}
zoom={initialZoom}
bind:map={mapInstance}
@@ -268,27 +278,31 @@
</Marker>
{/if}
{#each finds as find (find.id)}
<Marker lngLat={[parseFloat(find.longitude), parseFloat(find.latitude)]}>
{#each locations as location (location.id)}
<Marker lngLat={[parseFloat(location.longitude), parseFloat(location.latitude)]}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="find-marker"
class="location-pin-marker"
role="button"
tabindex="0"
onclick={() => onFindClick?.(find)}
title={find.title}
onclick={() => onLocationClick?.(location)}
title={`${location.finds.length} find${location.finds.length !== 1 ? 's' : ''}`}
>
<div class="find-marker-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<div class="location-pin-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path
d="M12 2L13.09 8.26L19 9.27L13.09 10.28L12 16.54L10.91 10.28L5 9.27L10.91 8.26L12 2Z"
d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"
fill="currentColor"
/>
<circle cx="12" cy="9" r="2.5" fill="white" />
</svg>
</div>
{#if find.media && find.media.length > 0}
<div class="find-marker-preview">
<img src={find.media[0].thumbnailUrl} alt={find.title} />
<div class="location-find-count">
{location.finds.length}
</div>
{#if location.finds.length > 0 && location.finds[0].media && location.finds[0].media.length > 0}
<div class="location-marker-preview">
<img src={location.finds[0].media[0].thumbnailUrl} alt="Preview" />
</div>
{/if}
</div>
@@ -473,42 +487,62 @@
}
}
/* Find marker styles */
:global(.find-marker) {
width: 40px;
height: 40px;
/* Location pin marker styles */
:global(.location-pin-marker) {
width: 50px;
height: 50px;
cursor: pointer;
position: relative;
transform: translate(-50%, -50%);
transform: translate(-50%, -100%);
transition: all 0.2s ease;
display: flex;
flex-direction: column;
align-items: center;
}
:global(.find-marker:hover) {
transform: translate(-50%, -50%) scale(1.1);
:global(.location-pin-marker:hover) {
transform: translate(-50%, -100%) scale(1.1);
z-index: 100;
}
:global(.find-marker-icon) {
width: 32px;
height: 32px;
background: #ff6b35;
border: 3px solid white;
border-radius: 50%;
:global(.location-pin-icon) {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
color: #ff6b35;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
position: relative;
z-index: 2;
}
:global(.find-marker-preview) {
:global(.location-find-count) {
position: absolute;
top: -8px;
right: -8px;
width: 20px;
height: 20px;
top: 2px;
left: 50%;
transform: translateX(-50%);
background: white;
color: #ff6b35;
font-weight: 600;
font-size: 11px;
min-width: 18px;
height: 18px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
z-index: 3;
}
:global(.location-marker-preview) {
position: absolute;
top: -2px;
right: -4px;
width: 24px;
height: 24px;
border-radius: 50%;
overflow: hidden;
border: 2px solid white;
@@ -516,7 +550,7 @@
z-index: 3;
}
:global(.find-marker-preview img) {
:global(.location-marker-preview img) {
width: 100%;
height: 100%;
object-fit: cover;

View File

@@ -273,7 +273,7 @@
top: 100%;
left: 0;
right: 0;
background: hsl(var(--background));
background: hsl(var(--background) / 0.95);
border: 1px solid hsl(var(--border));
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
@@ -281,7 +281,7 @@
overflow-y: auto;
z-index: 1000;
margin-top: 0.25rem;
backdrop-filter: blur(8px);
backdrop-filter: blur(12px);
}
.suggestions-header {
@@ -290,7 +290,7 @@
font-weight: 600;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--muted));
background: hsl(var(--muted) / 0.95);
backdrop-filter: blur(12px);
}
@@ -301,7 +301,7 @@
justify-content: space-between;
padding: 0.75rem;
border: none;
background: hsl(var(--background));
background: hsl(var(--background) / 0.95);
text-align: left;
cursor: pointer;
transition: background-color 0.2s ease;
@@ -314,7 +314,7 @@
}
.suggestion-item:hover:not(:disabled) {
background: hsl(var(--muted) / 0.5);
background: hsl(var(--muted) / 0.8);
}
.suggestion-item:disabled {

View File

@@ -161,7 +161,7 @@
/**
* Convert VAPID public key from base64 to Uint8Array
*/
function urlBase64ToUint8Array(base64String: string): Uint8Array {
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
@@ -171,7 +171,7 @@
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
return outputArray as Uint8Array<ArrayBuffer>;
}
/**

View File

@@ -1,4 +1,4 @@
import { pgTable, integer, text, timestamp, boolean, jsonb } from 'drizzle-orm/pg-core';
import { pgTable, integer, text, timestamp, boolean, jsonb, real } from 'drizzle-orm/pg-core';
export const user = pgTable('user', {
id: text('id').primaryKey(),
@@ -21,18 +21,34 @@ export type Session = typeof session.$inferSelect;
export type User = typeof user.$inferSelect;
// Finds feature tables
// Location table - represents geographical points where finds can be made
export const location = pgTable('location', {
id: text('id').primaryKey(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
latitude: text('latitude').notNull(), // Using text for precision
longitude: text('longitude').notNull(), // Using text for precision
locationName: text('location_name'), // e.g., "Café Belga, Brussels"
averageRating: integer('average_rating'), // Average rating (1-5 scale, stored as integer * 100 for precision)
ratingCount: integer('rating_count').default(0), // Total number of finds with ratings at this location
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
// Find table - represents posts/content made at a location
export const find = pgTable('find', {
id: text('id').primaryKey(),
locationId: text('location_id')
.notNull()
.references(() => location.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
description: text('description'),
latitude: text('latitude').notNull(), // Using text for precision
longitude: text('longitude').notNull(), // Using text for precision
locationName: text('location_name'), // e.g., "Café Belga, Brussels"
category: text('category'), // e.g., "cafe", "restaurant", "park", "landmark"
rating: integer('rating'), // Average rating for this find (1-5 stars, stored as integer * 100)
ratingCount: integer('rating_count').default(0), // Number of ratings for this find
isPublic: integer('is_public').default(1), // Using integer for boolean (1 = true, 0 = false)
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
@@ -63,6 +79,19 @@ export const findLike = pgTable('find_like', {
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
export const findRating = pgTable('find_rating', {
id: text('id').primaryKey(),
findId: text('find_id')
.notNull()
.references(() => find.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
rating: integer('rating').notNull(), // 1-5 stars (stored as 100-500 for precision)
createdAt: timestamp('created_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true, mode: 'date' }).defaultNow().notNull()
});
export const friendship = pgTable('friendship', {
id: text('id').primaryKey(),
userId: text('user_id')
@@ -130,18 +159,22 @@ export const notificationPreferences = pgTable('notification_preferences', {
});
// Type exports for the tables
export type Location = typeof location.$inferSelect;
export type Find = typeof find.$inferSelect;
export type FindMedia = typeof findMedia.$inferSelect;
export type FindLike = typeof findLike.$inferSelect;
export type FindRating = typeof findRating.$inferSelect;
export type FindComment = typeof findComment.$inferSelect;
export type Friendship = typeof friendship.$inferSelect;
export type Notification = typeof notification.$inferSelect;
export type NotificationSubscription = typeof notificationSubscription.$inferSelect;
export type NotificationPreferences = typeof notificationPreferences.$inferSelect;
export type LocationInsert = typeof location.$inferInsert;
export type FindInsert = typeof find.$inferInsert;
export type FindMediaInsert = typeof findMedia.$inferInsert;
export type FindLikeInsert = typeof findLike.$inferInsert;
export type FindRatingInsert = typeof findRating.$inferInsert;
export type FindCommentInsert = typeof findComment.$inferInsert;
export type FriendshipInsert = typeof friendship.$inferInsert;
export type NotificationInsert = typeof notification.$inferInsert;

View File

@@ -4,5 +4,5 @@ import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET } from '$env/static/private';
export const google = new Google(
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
'https://serengo.ziasvannes.tech/login/google/callback'
'https://serengo.zias.be/login/google/callback'
);

View File

@@ -44,6 +44,8 @@ export interface FindState {
longitude: string;
locationName?: string;
category?: string;
rating?: number | null;
ratingCount?: number;
isPublic: boolean;
createdAt: Date;
userId: string;

41
src/lib/utils/distance.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* Calculate distance between two points using Haversine formula
* @param lat1 Latitude of first point
* @param lon1 Longitude of first point
* @param lat2 Latitude of second point
* @param lon2 Longitude of second point
* @returns Distance in kilometers
*/
export function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
const R = 6371; // Radius of the Earth in kilometers
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
const distance = R * c;
return distance;
}
function toRad(degrees: number): number {
return degrees * (Math.PI / 180);
}
/**
* Format distance for display
* @param distance Distance in kilometers
* @returns Formatted string
*/
export function formatDistance(distance: number): string {
if (distance < 1) {
return `${Math.round(distance * 1000)}m`;
} else if (distance < 10) {
return `${distance.toFixed(1)}km`;
} else {
return `${Math.round(distance)}km`;
}
}

View File

@@ -4,23 +4,12 @@
import { Header } from '$lib';
import { page } from '$app/state';
import { Toaster } from '$lib/components/sonner/index.js';
import { Skeleton } from '$lib/components/skeleton';
import LocationManager from '$lib/components/map/LocationManager.svelte';
import NotificationManager from '$lib/components/notifications/NotificationManager.svelte';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
let { children, data } = $props();
let isLoginRoute = $derived(page.url.pathname.startsWith('/login'));
let showHeader = $derived(!isLoginRoute && data?.user);
let isLoading = $state(false);
// Handle loading state only on client to prevent hydration mismatch
onMount(() => {
if (browser) {
isLoading = !isLoginRoute && !data?.user && data !== null;
}
});
</script>
<svelte:head>
@@ -51,40 +40,6 @@
{#if showHeader && data.user}
<Header user={data.user} />
{:else if isLoading}
<header class="header-skeleton">
<div class="header-content">
<Skeleton class="h-8 w-32" />
<Skeleton class="h-10 w-10 rounded-full" />
</div>
</header>
{/if}
{@render children?.()}
<style>
.header-skeleton {
padding: 0 20px;
height: 64px;
display: flex;
align-items: center;
position: relative;
z-index: 100;
}
.header-content {
max-width: 1200px;
width: 100%;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
@media (max-width: 768px) {
.header-skeleton {
padding: 0 16px;
height: 56px;
}
}
</style>

View File

@@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
// Build API URL with query parameters
const apiUrl = new URL('/api/finds', url.origin);
const apiUrl = new URL('/api/locations', url.origin);
// Forward location filtering parameters
const lat = url.searchParams.get('lat');
@@ -32,15 +32,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch, request }) => {
throw new Error(`API request failed: ${response.status}`);
}
const finds = await response.json();
const locations = await response.json();
return {
finds
locations
};
} catch (err) {
console.error('Error loading finds:', err);
console.error('Error loading locations:', err);
return {
finds: []
locations: []
};
}
};

View File

@@ -1,65 +1,32 @@
<script lang="ts">
import { Map } from '$lib';
import FindsList from '$lib/components/finds/FindsList.svelte';
import CreateFindModal from '$lib/components/finds/CreateFindModal.svelte';
import EditFindModal from '$lib/components/finds/EditFindModal.svelte';
import FindPreview from '$lib/components/finds/FindPreview.svelte';
import FindsFilter from '$lib/components/finds/FindsFilter.svelte';
import {
LocationsList,
SelectLocationModal,
LocationFindsModal
} from '$lib/components/locations';
import type { PageData } from './$types';
import { coordinates } from '$lib/stores/location';
import { Button } from '$lib/components/button';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { apiSync, type FindState } from '$lib/stores/api-sync';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { calculateDistance } from '$lib/utils/distance';
// Server response type
interface ServerFind {
interface Find {
id: string;
locationId: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
locationName?: string;
rating?: number | null;
ratingCount?: number;
isPublic: number;
createdAt: string; // Will be converted to Date type, but is a string from api
userId: string;
username: string;
profilePictureUrl?: string | null;
likeCount?: number;
isLikedByUser?: boolean;
isFromFriend?: boolean;
media: Array<{
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}>;
}
// Map component type
interface MapFind {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
isPublic: number;
createdAt: Date;
userId: string;
user: {
id: string;
username: string;
profilePictureUrl?: string | null;
};
likeCount?: number;
isLiked?: boolean;
isFromFriend?: boolean;
createdAt: string;
media?: Array<{
id: string;
type: string;
@@ -69,223 +36,130 @@
}>;
}
// Interface for FindPreview component
interface FindPreviewData {
interface Location {
id: string;
title: string;
description?: string;
latitude: string;
longitude: string;
locationName?: string;
category?: string;
createdAt: string;
userId: string;
username: string;
profilePictureUrl?: string | null;
findCount: number;
finds?: Find[];
distance?: number;
}
interface MapLocation {
id: string;
latitude: string;
longitude: string;
createdAt: Date;
userId: string;
user: {
id: string;
username: string;
profilePictureUrl?: string | null;
};
likeCount?: number;
isLiked?: boolean;
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
finds: Array<{
id: string;
title: string;
description?: string;
isPublic: number;
media?: Array<{
type: string;
url: string;
thumbnailUrl: string;
}>;
}>;
distance?: number;
}
let { data }: { data: PageData & { finds?: ServerFind[] } } = $props();
let { data }: { data: PageData & { locations?: Location[] } } = $props();
let showCreateModal = $state(false);
let showEditModal = $state(false);
let editingFind: ServerFind | null = $state(null);
let selectedFind: FindPreviewData | null = $state(null);
let currentFilter = $state('all');
let showCreateFindModal = $state(false);
let showLocationFindsModal = $state(false);
let selectedLocation: Location | null = $state(null);
let isSidebarVisible = $state(true);
// Subscribe to all finds from api-sync
const allFindsStore = apiSync.subscribeAllFinds();
let allFindsFromSync = $state<FindState[]>([]);
// Process locations with distance
let locations = $derived.by(() => {
if (!data.locations || !$coordinates) return data.locations || [];
// Initialize API sync with server data on mount
onMount(() => {
if (browser && data.finds && data.finds.length > 0) {
const findStates: FindState[] = data.finds.map((serverFind: ServerFind) => ({
id: serverFind.id,
title: serverFind.title,
description: serverFind.description,
latitude: serverFind.latitude,
longitude: serverFind.longitude,
locationName: serverFind.locationName,
category: serverFind.category,
isPublic: Boolean(serverFind.isPublic),
createdAt: new Date(serverFind.createdAt),
userId: serverFind.userId,
username: serverFind.username,
profilePictureUrl: serverFind.profilePictureUrl || undefined,
media: serverFind.media,
isLikedByUser: Boolean(serverFind.isLikedByUser),
likeCount: serverFind.likeCount || 0,
commentCount: 0,
isFromFriend: Boolean(serverFind.isFromFriend)
}));
apiSync.initializeFindData(findStates);
}
});
// Subscribe to find updates using $effect
$effect(() => {
const unsubscribe = allFindsStore.subscribe((finds) => {
allFindsFromSync = finds;
});
return unsubscribe;
});
// All finds - convert FindState to MapFind format
let allFinds = $derived(
allFindsFromSync.map((findState: FindState) => ({
id: findState.id,
title: findState.title,
description: findState.description,
latitude: findState.latitude,
longitude: findState.longitude,
locationName: findState.locationName,
category: findState.category,
isPublic: findState.isPublic ? 1 : 0,
createdAt: findState.createdAt,
userId: findState.userId,
user: {
id: findState.userId,
username: findState.username,
profilePictureUrl: findState.profilePictureUrl || undefined
},
likeCount: findState.likeCount,
isLiked: findState.isLikedByUser,
isFromFriend: findState.isFromFriend,
media: findState.media?.map((m) => ({
id: m.id,
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url,
orderIndex: m.orderIndex
return data.locations
.map((loc: Location) => ({
...loc,
distance: calculateDistance(
$coordinates.latitude,
$coordinates.longitude,
parseFloat(loc.latitude),
parseFloat(loc.longitude)
)
}))
})) as MapFind[]
.sort(
(a: Location & { distance?: number }, b: Location & { distance?: number }) =>
(a.distance || 0) - (b.distance || 0)
);
});
// Convert locations to map markers - keep the full location object
let mapLocations: MapLocation[] = $derived(
locations.map(
(loc: Location): MapLocation => ({
id: loc.id,
latitude: loc.latitude,
longitude: loc.longitude,
createdAt: new Date(loc.createdAt),
userId: loc.userId,
user: {
id: loc.userId,
username: loc.username
},
finds: (loc.finds || []).map((find) => ({
id: find.id,
title: find.title,
description: find.description,
isPublic: find.isPublic,
media: find.media || []
})),
distance: loc.distance
})
)
);
// Filtered finds based on current filter
let finds = $derived.by(() => {
if (!data.user) return allFinds;
switch (currentFilter) {
case 'public':
return allFinds.filter((find) => find.isPublic === 1);
case 'friends':
return allFinds.filter((find) => find.isFromFriend === true);
case 'mine':
return allFinds.filter((find) => find.userId === data.user!.id);
case 'all':
default:
return allFinds;
}
});
function handleFilterChange(filter: string) {
currentFilter = filter;
}
function handleFindCreated(event: CustomEvent) {
// For now, just close modal and refresh page as in original implementation
showCreateModal = false;
if (event.detail?.reload) {
window.location.reload();
function handleLocationExplore(id: string) {
const location = locations.find((l: Location) => l.id === id);
if (location) {
selectedLocation = location;
showLocationFindsModal = true;
}
}
function handleFindClick(find: MapFind) {
// Convert MapFind to FindPreviewData format
selectedFind = {
id: find.id,
title: find.title,
description: find.description,
latitude: find.latitude,
longitude: find.longitude,
locationName: find.locationName,
category: find.category,
createdAt: find.createdAt.toISOString(),
user: find.user,
likeCount: find.likeCount,
isLiked: find.isLiked,
media: find.media?.map((m) => ({
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url
}))
};
function handleMapLocationClick(location: MapLocation) {
handleLocationExplore(location.id);
}
function handleFindExplore(id: string) {
// Find the specific find and show preview
const find = finds.find((f) => f.id === id);
if (find) {
handleFindClick(find);
}
function openCreateFindModal() {
showCreateFindModal = true;
}
function closeFindPreview() {
selectedFind = null;
function closeCreateFindModal() {
showCreateFindModal = false;
}
function openCreateModal() {
showCreateModal = true;
function closeLocationFindsModal() {
showLocationFindsModal = false;
selectedLocation = null;
}
function closeCreateModal() {
showCreateModal = false;
function handleFindCreated() {
closeCreateFindModal();
// Reload page to show new find
window.location.reload();
}
function openEditModal(find: MapFind) {
// Convert MapFind type to ServerFind format
const serverFind: ServerFind = {
id: find.id,
title: find.title,
description: find.description,
latitude: find.latitude || '0',
longitude: find.longitude || '0',
locationName: find.locationName,
category: find.category,
isPublic: find.isPublic || 0,
createdAt: find.createdAt.toISOString(),
userId: find.userId || '',
username: find.user?.username || '',
profilePictureUrl: find.user?.profilePictureUrl,
likeCount: find.likeCount,
isLikedByUser: find.isLiked,
isFromFriend: find.isFromFriend || false,
media: (find.media || []).map((mediaItem) => ({
...mediaItem,
findId: find.id,
thumbnailUrl: mediaItem.thumbnailUrl || null,
orderIndex: mediaItem.orderIndex || null
}))
};
editingFind = serverFind;
showEditModal = true;
}
function closeEditModal() {
showEditModal = false;
editingFind = null;
}
function handleFindUpdated() {
closeEditModal();
// api-sync handles the update, no manual reload needed
}
function handleFindDeleted() {
closeEditModal();
// api-sync handles the deletion, no manual reload needed
function handleCreateFindFromLocation() {
// Close location modal and open create find modal
showLocationFindsModal = false;
showCreateFindModal = true;
}
function toggleSidebar() {
@@ -319,46 +193,20 @@
<Map
autoCenter={true}
center={[$coordinates?.longitude || 0, $coordinates?.latitude || 51.505]}
finds={finds.map((find) => ({
id: find.id,
title: find.title,
description: find.description,
latitude: find.latitude,
longitude: find.longitude,
locationName: find.locationName,
category: find.category,
isPublic: find.isPublic,
createdAt: find.createdAt,
userId: find.userId,
user: {
id: find.user.id,
username: find.user.username
},
media: find.media?.map((m) => ({
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl
}))
}))}
onFindClick={(mapFind) => {
// Find the corresponding MapFind from the finds array
const originalFind = finds.find((f) => f.id === mapFind.id);
if (originalFind) {
handleFindClick(originalFind);
}
}}
locations={mapLocations}
onLocationClick={handleMapLocationClick}
sidebarVisible={isSidebarVisible}
/>
</div>
<!-- Sidebar container -->
<div class="sidebar-container">
<!-- Left sidebar with finds list -->
<!-- Left sidebar with locations list -->
<div class="finds-sidebar" class:hidden={!isSidebarVisible}>
<div class="finds-header">
{#if data.user}
<FindsFilter {currentFilter} onFilterChange={handleFilterChange} />
<Button onclick={openCreateModal} class="create-find-button">
<h3 class="header-title">Locations</h3>
<Button onclick={openCreateFindModal} class="create-find-button">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" class="mr-2">
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" />
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" />
@@ -374,39 +222,7 @@
{/if}
</div>
<div class="finds-list-container">
<FindsList
finds={finds.map((find) => ({
id: find.id,
title: find.title,
description: find.description,
category: find.category,
locationName: find.locationName,
latitude: find.latitude,
longitude: find.longitude,
isPublic: find.isPublic,
userId: find.userId,
user: {
username: find.user.username,
profilePictureUrl: find.user.profilePictureUrl
},
likeCount: find.likeCount,
isLiked: find.isLiked,
media: find.media?.map((m) => ({
id: m.id,
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl,
orderIndex: m.orderIndex
}))
}))}
onFindExplore={handleFindExplore}
currentUserId={data.user?.id}
onEdit={(find) => {
const mapFind = finds.find((f) => f.id === find.id);
if (mapFind) openEditModal(mapFind);
}}
hideTitle={true}
/>
<LocationsList {locations} onLocationExplore={handleLocationExplore} hideTitle={true} />
</div>
</div>
<!-- Toggle button -->
@@ -414,7 +230,7 @@
class="sidebar-toggle"
class:collapsed={!isSidebarVisible}
onclick={toggleSidebar}
aria-label="Toggle finds list"
aria-label="Toggle locations list"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
{#if isSidebarVisible}
@@ -428,38 +244,24 @@
</div>
<!-- Modals -->
{#if showCreateModal}
<CreateFindModal
isOpen={showCreateModal}
onClose={closeCreateModal}
{#if showCreateFindModal}
<SelectLocationModal
isOpen={showCreateFindModal}
onClose={closeCreateFindModal}
onFindCreated={handleFindCreated}
/>
{/if}
{#if showEditModal && editingFind}
<EditFindModal
isOpen={showEditModal}
find={{
id: editingFind.id,
title: editingFind.title,
description: editingFind.description || null,
latitude: editingFind.latitude || '0',
longitude: editingFind.longitude || '0',
locationName: editingFind.locationName || null,
category: editingFind.category || null,
isPublic: editingFind.isPublic,
media: editingFind.media || []
}}
onClose={closeEditModal}
onFindUpdated={handleFindUpdated}
onFindDeleted={handleFindDeleted}
{#if showLocationFindsModal && selectedLocation}
<LocationFindsModal
isOpen={showLocationFindsModal}
location={selectedLocation}
currentUserId={data.user?.id}
onClose={closeLocationFindsModal}
onCreateFind={handleCreateFindFromLocation}
/>
{/if}
{#if selectedFind}
<FindPreview find={selectedFind} onClose={closeFindPreview} currentUserId={data.user?.id} />
{/if}
<style>
.home-container {
position: relative;
@@ -521,7 +323,6 @@
width: 40%;
max-width: 1000px;
min-width: 500px;
height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
@@ -545,12 +346,21 @@
.finds-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(255, 255, 255, 0.5);
flex-shrink: 0;
}
.header-title {
font-family: 'Washington', serif;
font-size: 1.5rem;
font-weight: 600;
margin: 0;
color: hsl(var(--foreground));
}
.login-prompt {
width: 100%;
text-align: center;
@@ -598,6 +408,10 @@
flex-shrink: 0;
}
:global(.mr-2) {
margin-right: 0.5rem;
}
@media (max-width: 768px) {
.sidebar-container {
position: fixed;

View File

@@ -1,25 +1,27 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
import { location, find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
import { eq, and, sql, desc, or } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
import { getLocalR2Url } from '$lib/server/r2';
function generateFindId(): string {
function generateId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}
// GET endpoint now returns finds for a specific location
export const GET: RequestHandler = async ({ url, locals }) => {
const lat = url.searchParams.get('lat');
const lng = url.searchParams.get('lng');
const radius = url.searchParams.get('radius') || '50';
const locationId = url.searchParams.get('locationId');
const includePrivate = url.searchParams.get('includePrivate') === 'true';
const order = url.searchParams.get('order') || 'desc';
const includeFriends = url.searchParams.get('includeFriends') === 'true';
if (!locationId) {
throw error(400, 'locationId is required');
}
try {
// Get user's friends if needed and user is logged in
let friendIds: string[] = [];
@@ -58,41 +60,20 @@ export const GET: RequestHandler = async ({ url, locals }) => {
);
}
const baseCondition = sql`(${sql.join(conditions, sql` OR `)})`;
const privacyCondition = sql`(${sql.join(conditions, sql` OR `)})`;
const whereConditions = and(eq(find.locationId, locationId), privacyCondition);
let whereConditions = baseCondition;
// Add location filtering if coordinates provided
if (lat && lng) {
const radiusKm = parseFloat(radius);
const latOffset = radiusKm / 111;
const lngOffset = radiusKm / (111 * Math.cos((parseFloat(lat) * Math.PI) / 180));
const locationConditions = and(
baseCondition,
sql`${find.latitude} BETWEEN ${parseFloat(lat) - latOffset} AND ${
parseFloat(lat) + latOffset
}`,
sql`${find.longitude} BETWEEN ${parseFloat(lng) - lngOffset} AND ${
parseFloat(lng) + lngOffset
}`
);
if (locationConditions) {
whereConditions = locationConditions;
}
}
// Get all finds with filtering, like counts, and user's liked status
// Get all finds at this location with filtering, like counts, and user's liked status
const finds = await db
.select({
id: find.id,
locationId: find.locationId,
title: find.title,
description: find.description,
latitude: find.latitude,
longitude: find.longitude,
locationName: find.locationName,
locationName: location.locationName,
category: find.category,
rating: find.rating,
ratingCount: find.ratingCount,
isPublic: find.isPublic,
createdAt: find.createdAt,
userId: find.userId,
@@ -119,11 +100,11 @@ export const GET: RequestHandler = async ({ url, locals }) => {
})
.from(find)
.innerJoin(user, eq(find.userId, user.id))
.innerJoin(location, eq(find.locationId, location.id))
.leftJoin(findLike, eq(find.id, findLike.findId))
.where(whereConditions)
.groupBy(find.id, user.username, user.profilePictureUrl)
.orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt)
.limit(100);
.groupBy(find.id, user.username, user.profilePictureUrl, location.locationName)
.orderBy(order === 'desc' ? desc(find.createdAt) : find.createdAt);
// Get media for all finds
const findIds = finds.map((f) => f.id);
@@ -176,12 +157,11 @@ export const GET: RequestHandler = async ({ url, locals }) => {
// Generate signed URLs for all media items
const mediaWithSignedUrls = await Promise.all(
findMedia.map(async (mediaItem) => {
// URLs in database are now paths, generate local proxy URLs
const localUrl = getLocalR2Url(mediaItem.url);
const localThumbnailUrl =
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
? getLocalR2Url(mediaItem.thumbnailUrl)
: mediaItem.thumbnailUrl; // Keep static placeholder paths as-is
: mediaItem.thumbnailUrl;
return {
...mediaItem,
@@ -214,16 +194,17 @@ export const GET: RequestHandler = async ({ url, locals }) => {
}
};
// POST endpoint creates a find (post) at a location
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const data = await request.json();
const { title, description, latitude, longitude, locationName, category, isPublic, media } = data;
const { locationId, title, description, category, isPublic, media } = data;
if (!title || !latitude || !longitude) {
throw error(400, 'Title, latitude, and longitude are required');
if (!title || !locationId) {
throw error(400, 'Title and locationId are required');
}
if (title.length > 100) {
@@ -234,19 +215,28 @@ export const POST: RequestHandler = async ({ request, locals }) => {
throw error(400, 'Description must be 500 characters or less');
}
const findId = generateFindId();
// Verify location exists
const locationExists = await db
.select({ id: location.id })
.from(location)
.where(eq(location.id, locationId))
.limit(1);
if (locationExists.length === 0) {
throw error(404, 'Location not found');
}
const findId = generateId();
// Create find
const newFind = await db
.insert(find)
.values({
id: findId,
locationId,
userId: locals.user.id,
title,
description,
latitude: latitude.toString(),
longitude: longitude.toString(),
locationName,
category,
isPublic: isPublic ? 1 : 0
})
@@ -256,7 +246,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
if (media && media.length > 0) {
const mediaRecords = media.map(
(item: { type: string; url: string; thumbnailUrl?: string }, index: number) => ({
id: generateFindId(),
id: generateId(),
findId,
type: item.type,
url: item.url,

View File

@@ -1,7 +1,7 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { find, findMedia, user, findLike, findComment } from '$lib/server/db/schema';
import { find, findMedia, user, findLike, findComment, location } from '$lib/server/db/schema';
import { eq, sql } from 'drizzle-orm';
import { getLocalR2Url, deleteFromR2 } from '$lib/server/r2';
@@ -19,10 +19,12 @@ export const GET: RequestHandler = async ({ params, locals }) => {
id: find.id,
title: find.title,
description: find.description,
latitude: find.latitude,
longitude: find.longitude,
locationName: find.locationName,
latitude: location.latitude,
longitude: location.longitude,
locationName: location.locationName,
category: find.category,
rating: find.rating,
ratingCount: find.ratingCount,
isPublic: find.isPublic,
createdAt: find.createdAt,
userId: find.userId,
@@ -42,10 +44,18 @@ export const GET: RequestHandler = async ({ params, locals }) => {
: sql<boolean>`0`
})
.from(find)
.innerJoin(location, eq(find.locationId, location.id))
.innerJoin(user, eq(find.userId, user.id))
.leftJoin(findLike, eq(find.id, findLike.findId))
.where(eq(find.id, findId))
.groupBy(find.id, user.username, user.profilePictureUrl)
.groupBy(
find.id,
location.latitude,
location.longitude,
location.locationName,
user.username,
user.profilePictureUrl
)
.limit(1);
if (findResult.length === 0) {
@@ -143,21 +153,11 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
// Parse request body
const data = await request.json();
const {
title,
description,
latitude,
longitude,
locationName,
category,
isPublic,
media,
mediaToDelete
} = data;
const { title, description, category, isPublic, media, mediaToDelete } = data;
// Validate required fields
if (!title || !latitude || !longitude) {
throw error(400, 'Title, latitude, and longitude are required');
if (!title) {
throw error(400, 'Title is required');
}
if (title.length > 100) {
@@ -209,9 +209,6 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
.set({
title,
description: description || null,
latitude: latitude.toString(),
longitude: longitude.toString(),
locationName: locationName || null,
category: category || null,
isPublic: isPublic ? 1 : 0,
updatedAt: new Date()

View File

@@ -0,0 +1,162 @@
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { findRating, find, location } from '$lib/server/db/schema';
import { eq, and } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
function generateRatingId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}
// POST /api/finds/[findId]/rate - Rate a find
export async function POST({
params,
request,
locals
}: {
params: { findId: string };
request: Request;
locals: { user: { id: string } };
}) {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const findId = params.findId;
if (!findId) {
throw error(400, 'Find ID is required');
}
try {
const body = await request.json();
const ratingValue = body.rating;
// Validate rating (1-5 stars)
if (!ratingValue || ratingValue < 1 || ratingValue > 5) {
throw error(400, 'Rating must be between 1 and 5');
}
// Convert to integer representation (100-500)
const ratingInt = Math.round(ratingValue * 100);
// Check if find exists
const findRecord = await db.select().from(find).where(eq(find.id, findId)).limit(1);
if (findRecord.length === 0) {
throw error(404, 'Find not found');
}
// Check if user has already rated this find
const existingRating = await db
.select()
.from(findRating)
.where(and(eq(findRating.findId, findId), eq(findRating.userId, locals.user.id)))
.limit(1);
if (existingRating.length > 0) {
// Update existing rating
await db
.update(findRating)
.set({
rating: ratingInt,
updatedAt: new Date()
})
.where(eq(findRating.id, existingRating[0].id));
} else {
// Create new rating
const ratingId = generateRatingId();
await db.insert(findRating).values({
id: ratingId,
findId,
userId: locals.user.id,
rating: ratingInt
});
}
// Recalculate average rating for the find
const ratings = await db.select().from(findRating).where(eq(findRating.findId, findId));
const avgRating = ratings.reduce((sum, r) => sum + (r.rating || 0), 0) / ratings.length;
const roundedAvgRating = Math.round(avgRating);
await db
.update(find)
.set({
rating: roundedAvgRating,
ratingCount: ratings.length,
updatedAt: new Date()
})
.where(eq(find.id, findId));
// Recalculate location average rating
const locationId = findRecord[0].locationId;
const locationFinds = await db.select().from(find).where(eq(find.locationId, locationId));
// Only include finds that have ratings
const ratedFinds = locationFinds.filter((f) => f.rating !== null && (f.ratingCount || 0) > 0);
if (ratedFinds.length > 0) {
const locationAvgRating =
ratedFinds.reduce((sum, f) => sum + (f.rating || 0), 0) / ratedFinds.length;
const roundedLocationAvgRating = Math.round(locationAvgRating);
await db
.update(location)
.set({
averageRating: roundedLocationAvgRating,
ratingCount: ratedFinds.length
})
.where(eq(location.id, locationId));
}
return json({
success: true,
rating: roundedAvgRating,
ratingCount: ratings.length
});
} catch (err) {
console.error('Error rating find:', err);
throw error(500, 'Failed to rate find');
}
}
// GET /api/finds/[findId]/rate - Get user's rating for a find
export async function GET({
params,
locals
}: {
params: { findId: string };
locals: { user: { id: string } };
}) {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const findId = params.findId;
if (!findId) {
throw error(400, 'Find ID is required');
}
try {
const userRating = await db
.select()
.from(findRating)
.where(and(eq(findRating.findId, findId), eq(findRating.userId, locals.user.id)))
.limit(1);
if (userRating.length === 0) {
return json({ rating: null });
}
// Convert from integer representation to float (100-500 -> 1-5)
const ratingValue = (userRating[0].rating || 0) / 100;
return json({ rating: ratingValue });
} catch (err) {
console.error('Error getting user rating:', err);
throw error(500, 'Failed to get rating');
}
}

View File

@@ -0,0 +1,265 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { location, find, findMedia, user, findLike, friendship } from '$lib/server/db/schema';
import { eq, and, sql, desc, or } from 'drizzle-orm';
import { encodeBase64url } from '@oslojs/encoding';
import { getLocalR2Url } from '$lib/server/r2';
function generateId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(15));
return encodeBase64url(bytes);
}
export const GET: RequestHandler = async ({ url, locals }) => {
const lat = url.searchParams.get('lat');
const lng = url.searchParams.get('lng');
const radius = url.searchParams.get('radius') || '50';
const includePrivate = url.searchParams.get('includePrivate') === 'true';
const order = url.searchParams.get('order') || 'desc';
const includeFriends = url.searchParams.get('includeFriends') === 'true';
try {
// Get user's friends if needed and user is logged in
let friendIds: string[] = [];
if (locals.user && (includeFriends || includePrivate)) {
const friendships = await db
.select({
userId: friendship.userId,
friendId: friendship.friendId
})
.from(friendship)
.where(
and(
eq(friendship.status, 'accepted'),
or(eq(friendship.userId, locals.user.id), eq(friendship.friendId, locals.user.id))
)
);
friendIds = friendships.map((f) => (f.userId === locals.user!.id ? f.friendId : f.userId));
}
// Build base condition for locations (always public since locations don't have privacy)
let whereConditions = sql`1=1`;
// Add location filtering if coordinates provided
if (lat && lng) {
const radiusKm = parseFloat(radius);
const latOffset = radiusKm / 111;
const lngOffset = radiusKm / (111 * Math.cos((parseFloat(lat) * Math.PI) / 180));
whereConditions = and(
whereConditions,
sql`${location.latitude} BETWEEN ${parseFloat(lat) - latOffset} AND ${
parseFloat(lat) + latOffset
}`,
sql`${location.longitude} BETWEEN ${parseFloat(lng) - lngOffset} AND ${
parseFloat(lng) + lngOffset
}`
)!;
}
// Get all locations with their find counts
const locations = await db
.select({
id: location.id,
latitude: location.latitude,
longitude: location.longitude,
locationName: location.locationName,
createdAt: location.createdAt,
userId: location.userId,
username: user.username,
profilePictureUrl: user.profilePictureUrl,
findCount: sql<number>`COALESCE(COUNT(DISTINCT ${find.id}), 0)`
})
.from(location)
.innerJoin(user, eq(location.userId, user.id))
.leftJoin(find, eq(location.id, find.locationId))
.where(whereConditions)
.groupBy(location.id, user.username, user.profilePictureUrl)
.orderBy(order === 'desc' ? desc(location.createdAt) : location.createdAt)
.limit(100);
// For each location, get finds with privacy filtering
const locationsWithFinds = await Promise.all(
locations.map(async (loc) => {
// Build privacy conditions for finds
const findConditions = [sql`${find.isPublic} = 1`]; // Always include public finds
if (locals.user && includePrivate) {
// Include user's own finds
findConditions.push(sql`${find.userId} = ${locals.user.id}`);
}
if (locals.user && includeFriends && friendIds.length > 0) {
// Include friends' finds
findConditions.push(
sql`${find.userId} IN (${sql.join(
friendIds.map((id) => sql`${id}`),
sql`, `
)})`
);
}
const findPrivacyCondition = sql`(${sql.join(findConditions, sql` OR `)})`;
// Get finds for this location
const finds = await db
.select({
id: find.id,
title: find.title,
description: find.description,
category: find.category,
rating: find.rating,
ratingCount: find.ratingCount,
isPublic: find.isPublic,
createdAt: find.createdAt,
userId: find.userId,
username: user.username,
profilePictureUrl: user.profilePictureUrl,
likeCount: sql<number>`COALESCE(COUNT(DISTINCT ${findLike.id}), 0)`,
isLikedByUser: locals.user
? sql<boolean>`CASE WHEN EXISTS(
SELECT 1 FROM ${findLike}
WHERE ${findLike.findId} = ${find.id}
AND ${findLike.userId} = ${locals.user.id}
) THEN 1 ELSE 0 END`
: sql<boolean>`0`
})
.from(find)
.innerJoin(user, eq(find.userId, user.id))
.leftJoin(findLike, eq(find.id, findLike.findId))
.where(and(eq(find.locationId, loc.id), findPrivacyCondition))
.groupBy(find.id, user.username, user.profilePictureUrl)
.orderBy(desc(find.createdAt));
// Get media for all finds at this location
const findIds = finds.map((f) => f.id);
let media: Array<{
id: string;
findId: string;
type: string;
url: string;
thumbnailUrl: string | null;
orderIndex: number | null;
}> = [];
if (findIds.length > 0) {
media = await db
.select({
id: findMedia.id,
findId: findMedia.findId,
type: findMedia.type,
url: findMedia.url,
thumbnailUrl: findMedia.thumbnailUrl,
orderIndex: findMedia.orderIndex
})
.from(findMedia)
.where(
sql`${findMedia.findId} IN (${sql.join(
findIds.map((id) => sql`${id}`),
sql`, `
)})`
)
.orderBy(findMedia.orderIndex);
}
// Group media by find
const mediaByFind = media.reduce(
(acc, item) => {
if (!acc[item.findId]) {
acc[item.findId] = [];
}
acc[item.findId].push(item);
return acc;
},
{} as Record<string, typeof media>
);
// Combine finds with their media and generate signed URLs
const findsWithMedia = await Promise.all(
finds.map(async (findItem) => {
const findMedia = mediaByFind[findItem.id] || [];
// Generate signed URLs for all media items
const mediaWithSignedUrls = await Promise.all(
findMedia.map(async (mediaItem) => {
const localUrl = getLocalR2Url(mediaItem.url);
const localThumbnailUrl =
mediaItem.thumbnailUrl && !mediaItem.thumbnailUrl.startsWith('/')
? getLocalR2Url(mediaItem.thumbnailUrl)
: mediaItem.thumbnailUrl;
return {
...mediaItem,
url: localUrl,
thumbnailUrl: localThumbnailUrl
};
})
);
// Generate local proxy URL for user profile picture if it exists
let userProfilePictureUrl = findItem.profilePictureUrl;
if (userProfilePictureUrl && !userProfilePictureUrl.startsWith('http')) {
userProfilePictureUrl = getLocalR2Url(userProfilePictureUrl);
}
return {
...findItem,
profilePictureUrl: userProfilePictureUrl,
media: mediaWithSignedUrls,
isLikedByUser: Boolean(findItem.isLikedByUser)
};
})
);
// Generate local proxy URL for location creator profile picture
let locProfilePictureUrl = loc.profilePictureUrl;
if (locProfilePictureUrl && !locProfilePictureUrl.startsWith('http')) {
locProfilePictureUrl = getLocalR2Url(locProfilePictureUrl);
}
return {
...loc,
profilePictureUrl: locProfilePictureUrl,
finds: findsWithMedia
};
})
);
return json(locationsWithFinds);
} catch (err) {
console.error('Error loading locations:', err);
throw error(500, 'Failed to load locations');
}
};
export const POST: RequestHandler = async ({ request, locals }) => {
if (!locals.user) {
throw error(401, 'Unauthorized');
}
const data = await request.json();
const { latitude, longitude, locationName } = data;
if (!latitude || !longitude) {
throw error(400, 'Latitude and longitude are required');
}
const locationId = generateId();
// Create location
const newLocation = await db
.insert(location)
.values({
id: locationId,
userId: locals.user.id,
latitude: latitude.toString(),
longitude: longitude.toString(),
locationName: locationName || null
})
.returning();
return json({ success: true, location: newLocation[0] });
};

View File

@@ -121,42 +121,36 @@
goto('/');
}
// Create the map find format
let mapFinds = $derived(
// Get first media for OG image
let ogImage = $derived(data.find?.media?.[0]?.url || '');
// Convert find to location format for map marker
let findAsLocation = $derived(
data.find
? [
{
id: data.find.id,
title: data.find.title,
description: data.find.description,
latitude: data.find.latitude,
longitude: data.find.longitude,
locationName: data.find.locationName,
category: data.find.category,
isPublic: data.find.isPublic,
createdAt: new Date(data.find.createdAt),
userId: data.find.userId,
user: {
id: data.find.userId,
username: data.find.username,
profilePictureUrl: data.find.profilePictureUrl
username: data.find.username
},
likeCount: data.find.likeCount,
isLiked: data.find.isLikedByUser,
media: data.find.media?.map(
(m: { type: string; url: string; thumbnailUrl: string | null }) => ({
type: m.type,
url: m.url,
thumbnailUrl: m.thumbnailUrl || m.url
})
)
finds: [
{
id: data.find.id,
title: data.find.title,
description: data.find.description || undefined,
isPublic: data.find.isPublic ?? 1,
media: data.find.media || []
}
]
}
]
: []
);
// Get first media for OG image
let ogImage = $derived(data.find?.media?.[0]?.url || '');
</script>
<svelte:head>
@@ -198,10 +192,10 @@
<!-- Fullscreen map -->
<div class="map-section">
<Map
autoCenter={true}
autoCenter={false}
center={[parseFloat(data.find?.longitude || '0'), parseFloat(data.find?.latitude || '0')]}
finds={mapFinds}
onFindClick={() => {}}
zoom={15}
locations={findAsLocation}
/>
</div>
@@ -493,7 +487,6 @@
width: 40%;
max-width: 1000px;
min-width: 500px;
height: calc(100vh - 100px);
backdrop-filter: blur(10px);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);

View File

@@ -0,0 +1,24 @@
{
"version": 8,
"name": "Dark Matter",
"sources": {
"carto-dark": {
"type": "raster",
"tiles": [
"https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
"https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
"https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
"https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png"
],
"tileSize": 256,
"attribution": "&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors &copy; <a href=\"https://carto.com/attributions\">CARTO</a>"
}
},
"layers": [
{
"id": "carto-dark-layer",
"type": "raster",
"source": "carto-dark"
}
]
}

View File

@@ -0,0 +1,24 @@
{
"version": 8,
"name": "Positron (Light)",
"sources": {
"carto-light": {
"type": "raster",
"tiles": [
"https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
"https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
"https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
"https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png"
],
"tileSize": 256,
"attribution": "&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors &copy; <a href=\"https://carto.com/attributions\">CARTO</a>"
}
},
"layers": [
{
"id": "carto-light-layer",
"type": "raster",
"source": "carto-light"
}
]
}

View File

@@ -7,7 +7,7 @@ Disallow: /_app/
Disallow: /.svelte-kit/
# Sitemap location
Sitemap: https://serengo.ziasvannes.tech/sitemap.xml
Sitemap: https://serengo.zias.be/sitemap.xml
# Crawl delay for polite crawling
Crawl-delay: 1

View File

@@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-vercel';
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
@@ -13,7 +13,7 @@ const config = {
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
csrf: {
trustedOrigins: ['http://localhost:3000', 'https://serengo.ziasvannes.tech']
trustedOrigins: ['http://localhost:3000', 'https://serengo.zias.be']
},
alias: {
'@/*': './src/lib/*'

View File

@@ -4,7 +4,7 @@ import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
preview: { allowedHosts: ['ziasvannes.tech'] },
preview: { allowedHosts: ['zias.be'] },
build: {
target: 'es2020',
cssCodeSplit: true