diff --git a/app/main.py b/app/main.py index 78fcf07..6de2e6a 100644 --- a/app/main.py +++ b/app/main.py @@ -395,6 +395,44 @@ def ensure_schema(): CREATE INDEX IF NOT EXISTS idx_direct_messages_unread ON direct_messages (recipient_id, read_at) """, + """ + CREATE TABLE IF NOT EXISTS followed_bands ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + band_key VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, band_key) + ) + """, + """ + CREATE TABLE IF NOT EXISTS followed_venues ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, venue_id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS concert_diary ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), + favorite_song VARCHAR(255), + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, concert_id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS concert_bands ( + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + band_key VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + position SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (concert_id, band_key) + ) + """, ] with get_db_connection() as connection: @@ -1088,7 +1126,48 @@ def artist_names_similar(first: str, second: str) -> bool: return SequenceMatcher(None, left, right).ratio() >= 0.78 -def find_duplicate_concerts(user, artist: str, start_date: str): +def inferred_band_names(title: str, event_type: str = "concert") -> list[str]: + if event_type != "concert": + return [] + primary = re.split(r"\s+(?:-|–|—)\s+|:\s+", title.strip(), maxsplit=1)[0] + return [part.strip() for part in re.split(r"\s+(?:\+|/|&|and|und)\s+", primary) if part.strip()] + + +def parse_band_names(value: str, fallback_title: str = "", event_type: str = "concert") -> list[dict]: + names = [line.strip() for line in (value or "").splitlines() if line.strip()] + if not names: + names = inferred_band_names(fallback_title, event_type) + bands = [] + seen = set() + for name in names[:30]: + key = normalize_artist_name(name)[:255] + if key and key not in seen: + seen.add(key) + bands.append({"key": key, "name": name[:255]}) + return bands + + +def load_concert_bands(concert_id: int, title: str = "", event_type: str = "concert") -> list[dict]: + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + "SELECT band_key, display_name FROM concert_bands WHERE concert_id = %s ORDER BY position, display_name", + (concert_id,), + ) + bands = [{"key": row[0], "name": row[1]} for row in cursor.fetchall()] + return bands or parse_band_names("", title, event_type) + + +def replace_concert_bands(cursor, concert_id: int, bands: list[dict]): + cursor.execute("DELETE FROM concert_bands WHERE concert_id = %s", (concert_id,)) + for position, band in enumerate(bands): + cursor.execute( + "INSERT INTO concert_bands (concert_id, band_key, display_name, position) VALUES (%s, %s, %s, %s)", + (concert_id, band["key"], band["name"], position), + ) + + +def find_duplicate_concerts(user, artist: str, start_date: str, band_names: str = ""): try: concert_date = datetime.strptime(start_date[:10], "%Y-%m-%d").date() except (TypeError, ValueError): @@ -1120,6 +1199,15 @@ def find_duplicate_concerts(user, artist: str, start_date: str): (concert_date, user["id"], user["is_admin"], user["id"], user["id"], user["id"]), ) rows = cursor.fetchall() + row_ids = [row[0] for row in rows] + cursor.execute( + "SELECT concert_id, band_key, display_name FROM concert_bands WHERE concert_id = ANY(%s) ORDER BY position", + (row_ids or [0],), + ) + stored_bands = {} + for concert_id, band_key, display_name in cursor.fetchall(): + stored_bands.setdefault(concert_id, []).append({"key": band_key, "name": display_name}) + submitted_bands = parse_band_names(band_names, artist, "concert") return [ { "id": row[0], @@ -1130,7 +1218,11 @@ def find_duplicate_concerts(user, artist: str, start_date: str): "venue": ", ".join(part for part in (row[4], row[5]) if part), } for row in rows - if artist_names_similar(artist, row[1]) + if any( + artist_names_similar(submitted["name"], existing["name"]) + for submitted in submitted_bands + for existing in (stored_bands.get(row[0]) or parse_band_names("", row[1], row[3])) + ) or artist_names_similar(artist, row[1]) ] @@ -2626,6 +2718,22 @@ def export_profile_data(request: Request): ) photos = cursor.fetchall() + cursor.execute( + "SELECT band_key, display_name, created_at FROM followed_bands WHERE user_id = %s ORDER BY created_at", + (user_id,), + ) + followed_bands = cursor.fetchall() + cursor.execute( + "SELECT venue_id, created_at FROM followed_venues WHERE user_id = %s ORDER BY created_at", + (user_id,), + ) + followed_venues = cursor.fetchall() + cursor.execute( + "SELECT concert_id, rating, favorite_song, notes, created_at, updated_at FROM concert_diary WHERE user_id = %s ORDER BY updated_at", + (user_id,), + ) + diary_entries = cursor.fetchall() + cursor.execute( """ SELECT badge_code, awarded_at FROM user_badges @@ -2659,6 +2767,9 @@ def export_profile_data(request: Request): "event_invitations": rows_to_dicts(invitations, ("concert_id", "invited_by", "viewed_at", "created_at")), "comments": rows_to_dicts(comments, ("id", "concert_id", "body", "created_at")), "photos": rows_to_dicts(photos, ("id", "concert_id", "path", "created_at")), + "followed_bands": rows_to_dicts(followed_bands, ("band_key", "display_name", "created_at")), + "followed_venues": rows_to_dicts(followed_venues, ("venue_id", "created_at")), + "concert_diary": rows_to_dicts(diary_entries, ("concert_id", "rating", "favorite_song", "notes", "created_at", "updated_at")), "badges": rows_to_dicts(badges, ("badge_code", "awarded_at")), } filename = re.sub(r"[^A-Za-z0-9_-]", "_", user["username"]) @@ -3074,11 +3185,219 @@ def new_concert(request: Request): @app.get("/api/concerts/duplicates") -def duplicate_concerts(request: Request, artist: str = "", start_date: str = ""): +def duplicate_concerts(request: Request, artist: str = "", start_date: str = "", band_names: str = ""): user = get_current_user(request) if len(artist.strip()) < 2 or len(artist) > 300: return JSONResponse({"matches": []}) - return JSONResponse({"matches": find_duplicate_concerts(user, artist, start_date)}) + return JSONResponse({"matches": find_duplicate_concerts(user, artist, start_date, band_names)}) + + +@app.get("/following", response_class=HTMLResponse) +def following_page(request: Request): + user = get_current_user(request) + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + "SELECT band_key, display_name FROM followed_bands WHERE user_id = %s ORDER BY display_name", + (user["id"],), + ) + followed_bands = [{"key": row[0], "name": row[1]} for row in cursor.fetchall()] + cursor.execute( + """ + SELECT v.id, v.name, COALESCE(v.city, '') FROM followed_venues fv + JOIN venues v ON v.id = fv.venue_id + WHERE fv.user_id = %s ORDER BY v.name, v.city + """, + (user["id"],), + ) + followed_venues = [{"id": row[0], "name": row[1], "city": row[2]} for row in cursor.fetchall()] + cursor.execute( + """ + SELECT c.id, c.artist, c.start_datetime, c.venue_id, + COALESCE(v.name, ''), COALESCE(v.city, ''), c.visibility, c.created_by, + c.event_type + FROM concerts c LEFT JOIN venues v ON v.id = c.venue_id + WHERE COALESCE(c.end_datetime, c.start_datetime) >= CURRENT_TIMESTAMP + AND (c.visibility = 'public' OR c.created_by = %s OR %s + OR EXISTS (SELECT 1 FROM event_invitations ei WHERE ei.concert_id = c.id AND ei.user_id = %s) + OR (c.visibility = 'friends' AND EXISTS ( + SELECT 1 FROM friendships f WHERE f.status = 'accepted' + AND ((f.requester_id = c.created_by AND f.addressee_id = %s) + OR (f.addressee_id = c.created_by AND f.requester_id = %s))))) + ORDER BY c.start_datetime + """, + (user["id"], user["is_admin"], user["id"], user["id"], user["id"]), + ) + candidates = cursor.fetchall() + candidate_ids = [row[0] for row in candidates] + cursor.execute( + "SELECT concert_id, band_key, display_name FROM concert_bands WHERE concert_id = ANY(%s) ORDER BY position", + (candidate_ids or [0],), + ) + candidate_bands = {} + for concert_id, band_key, display_name in cursor.fetchall(): + candidate_bands.setdefault(concert_id, []).append({"key": band_key, "name": display_name}) + band_names = [band["name"] for band in followed_bands] + venue_ids = {venue["id"] for venue in followed_venues} + events = [ + { + "id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"), + "time": row[2].strftime("%H:%M"), "venue": ", ".join(filter(None, (row[4], row[5]))), + "matched_band": any( + artist_names_similar(event_band["name"], followed_band) + for event_band in (candidate_bands.get(row[0]) or parse_band_names("", row[1], row[8])) + for followed_band in band_names + ), + "matched_venue": row[3] in venue_ids, + } + for row in candidates + if row[3] in venue_ids or any( + artist_names_similar(event_band["name"], followed_band) + for event_band in (candidate_bands.get(row[0]) or parse_band_names("", row[1], row[8])) + for followed_band in band_names + ) + ] + return templates.get_template("following.html").render( + user=user, followed_bands=followed_bands, followed_venues=followed_venues, events=events + ) + + +@app.post("/following/bands/remove") +def remove_followed_band(request: Request, band_key: str = Form(...)): + user = get_current_user(request) + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("DELETE FROM followed_bands WHERE user_id = %s AND band_key = %s", (user["id"], band_key[:255])) + connection.commit() + return RedirectResponse("/following", status_code=303) + + +@app.post("/following/venues/remove") +def remove_followed_venue(request: Request, venue_id: int = Form(...)): + user = get_current_user(request) + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("DELETE FROM followed_venues WHERE user_id = %s AND venue_id = %s", (user["id"], venue_id)) + connection.commit() + return RedirectResponse("/following", status_code=303) + + +@app.post("/concerts/{concert_id}/follow-band") +def follow_band(request: Request, concert_id: int, band_key: str = Form(...), action: str = Form("follow")): + user = get_current_user(request) + concert = load_concert(concert_id) + if not concert or not can_view_event(user, concert): + return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404) + bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"]) + selected_band = next((band for band in bands if band["key"] == band_key), None) + if not selected_band: + return HTMLResponse("Band nicht gefunden.", status_code=404) + with get_db_connection() as connection: + with connection.cursor() as cursor: + if action == "unfollow": + cursor.execute("DELETE FROM followed_bands WHERE user_id = %s AND band_key = %s", (user["id"], band_key)) + elif action == "follow": + cursor.execute( + "INSERT INTO followed_bands (user_id, band_key, display_name) VALUES (%s, %s, %s) ON CONFLICT (user_id, band_key) DO UPDATE SET display_name = EXCLUDED.display_name", + (user["id"], band_key, selected_band["name"]), + ) + else: + return HTMLResponse("Ungültige Aktion.", status_code=400) + connection.commit() + return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303) + + +@app.post("/concerts/{concert_id}/follow-venue") +def follow_venue(request: Request, concert_id: int, action: str = Form("follow")): + user = get_current_user(request) + concert = load_concert(concert_id) + if not concert or not can_view_event(user, concert): + return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404) + venue_id = concert["venue"]["id"] + if not venue_id: + return HTMLResponse("Diese Veranstaltung hat keine zugeordnete Location.", status_code=400) + with get_db_connection() as connection: + with connection.cursor() as cursor: + if action == "unfollow": + cursor.execute("DELETE FROM followed_venues WHERE user_id = %s AND venue_id = %s", (user["id"], venue_id)) + elif action == "follow": + cursor.execute("INSERT INTO followed_venues (user_id, venue_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", (user["id"], venue_id)) + else: + return HTMLResponse("Ungültige Aktion.", status_code=400) + connection.commit() + return RedirectResponse(f"/concerts/{concert_id}#following", status_code=303) + + +@app.get("/diary", response_class=HTMLResponse) +def diary_page(request: Request): + user = get_current_user(request) + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT d.concert_id, c.artist, c.start_datetime, d.rating, + COALESCE(d.favorite_song, ''), COALESCE(d.notes, ''), + COALESCE(v.name, ''), COALESCE(v.city, '') + FROM concert_diary d JOIN concerts c ON c.id = d.concert_id + LEFT JOIN venues v ON v.id = c.venue_id + WHERE d.user_id = %s ORDER BY c.start_datetime DESC + """, + (user["id"],), + ) + entries = [ + {"concert_id": row[0], "artist": row[1], "date": row[2].strftime("%d.%m.%Y"), + "rating": row[3], "favorite_song": row[4], "notes": row[5], + "venue": ", ".join(filter(None, (row[6], row[7])))} + for row in cursor.fetchall() + ] + return templates.get_template("diary.html").render(user=user, entries=entries) + + +@app.post("/concerts/{concert_id}/diary") +def save_diary_entry( + request: Request, concert_id: int, rating: int = Form(...), + favorite_song: str = Form(""), notes: str = Form("") +): + user = get_current_user(request) + concert = load_concert(concert_id) + if not concert or not can_view_event(user, concert): + return HTMLResponse("Veranstaltung nicht gefunden.", status_code=404) + if not concert["is_past"] or rating not in range(1, 6): + return HTMLResponse("Das Tagebuch ist nur für vergangene Konzerte mit einer Bewertung von 1 bis 5 verfügbar.", status_code=400) + favorite_song = favorite_song.strip() + notes = notes.strip() + if len(favorite_song) > 255 or len(notes) > 5000: + return HTMLResponse("Tagebucheintrag ist zu lang.", status_code=400) + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute( + "SELECT 1 FROM concert_attendance WHERE concert_id = %s AND user_id = %s AND status = 'attending'", + (concert_id, user["id"]), + ) + if not cursor.fetchone(): + return HTMLResponse("Ein Tagebucheintrag ist nur für besuchte Konzerte möglich.", status_code=403) + cursor.execute( + """ + INSERT INTO concert_diary (user_id, concert_id, rating, favorite_song, notes) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (user_id, concert_id) DO UPDATE SET + rating = EXCLUDED.rating, favorite_song = EXCLUDED.favorite_song, + notes = EXCLUDED.notes, updated_at = CURRENT_TIMESTAMP + """, + (user["id"], concert_id, rating, favorite_song or None, notes or None), + ) + connection.commit() + return RedirectResponse(f"/concerts/{concert_id}#diary", status_code=303) + + +@app.post("/concerts/{concert_id}/diary/delete") +def delete_diary_entry(request: Request, concert_id: int): + user = get_current_user(request) + with get_db_connection() as connection: + with connection.cursor() as cursor: + cursor.execute("DELETE FROM concert_diary WHERE user_id = %s AND concert_id = %s", (user["id"], concert_id)) + connection.commit() + return RedirectResponse(f"/concerts/{concert_id}#diary", status_code=303) # ============================================================ @@ -3199,6 +3518,25 @@ def concert_detail(request: Request, concert_id: int): (concert_id, user["id"], user["id"]), ) attendance_rows = cursor.fetchall() + cursor.execute( + "SELECT band_key FROM followed_bands WHERE user_id = %s", + (user["id"],), + ) + followed_band_keys = {row[0] for row in cursor.fetchall()} + venue_id = concert["venue"]["id"] + if venue_id: + cursor.execute( + "SELECT EXISTS (SELECT 1 FROM followed_venues WHERE user_id = %s AND venue_id = %s)", + (user["id"], venue_id), + ) + follows_venue = cursor.fetchone()[0] + else: + follows_venue = False + cursor.execute( + "SELECT rating, COALESCE(favorite_song, ''), COALESCE(notes, '') FROM concert_diary WHERE user_id = %s AND concert_id = %s", + (user["id"], concert_id), + ) + diary_row = cursor.fetchone() comments = [ { @@ -3238,6 +3576,12 @@ def concert_detail(request: Request, concert_id: int): (item["status"] for item in attendance if item["user_id"] == user["id"]), None, ) + diary_entry = { + "rating": diary_row[0], "favorite_song": diary_row[1], "notes": diary_row[2] + } if diary_row else None + concert_bands = load_concert_bands(concert_id, concert["artist"], concert["event_type"]) + for band in concert_bands: + band["is_following"] = band["key"] in followed_band_keys template = templates.get_template("concert_detail.html") return template.render( @@ -3256,6 +3600,10 @@ def concert_detail(request: Request, concert_id: int): ticket_offer_count=len(ticket_offers), maybe_users=maybe_users, maybe_count=len(maybe_users), + concert_bands=concert_bands, + follows_venue=follows_venue, + diary_entry=diary_entry, + can_write_diary=concert["is_past"] and current_attendance == "attending", ) @@ -3267,6 +3615,7 @@ def concert_detail(request: Request, concert_id: int): async def create_concert( request: Request, artist: str = Form(...), + band_names: str = Form(""), event_type: str = Form("concert"), parent_event_id: str = Form(""), visibility: str = Form("public"), @@ -3298,6 +3647,7 @@ async def create_concert( return HTMLResponse("
💬 Kommentare
diff --git a/app/templates/datenschutz.html b/app/templates/datenschutz.html index 9a22d53..0c3f2b5 100644 --- a/app/templates/datenschutz.html +++ b/app/templates/datenschutz.html @@ -9,7 +9,7 @@Verantwortlicher
Kai Piekny, Fleyerstr. 33, 58097 Hagen
konzert@pinguholic.de
Welche Daten werden gespeichert?
-Für die Nutzung werden insbesondere Benutzername, E-Mail-Adresse, Passwort-Hash, Anzeigename, optionale Profil- und Instagram-Angaben, Profilbild, Freundschaften, Nachrichten, Veranstaltungsteilnahmen, Kommentare, Fotos, Patches sowie von dir angelegte Veranstaltungen gespeichert.
+Für die Nutzung werden insbesondere Benutzername, E-Mail-Adresse, Passwort-Hash, Anzeigename, optionale Profil- und Instagram-Angaben, Profilbild, Freundschaften, Nachrichten, Veranstaltungsteilnahmen, gefolgte Bands und Locations, private Konzerttagebuch-Einträge, Kommentare, Fotos, Patches sowie von dir angelegte Veranstaltungen gespeichert.
Wofür werden sie verwendet?
Die Daten werden ausschließlich für Anmeldung, Kontoverwaltung, Veranstaltungsfunktionen, Freundschaften, Nachrichten, Benachrichtigungen und die von dir gewählten Sichtbarkeitseinstellungen verarbeitet.
Rechtsgrundlage und Speicherdauer
diff --git a/app/templates/diary.html b/app/templates/diary.html new file mode 100644 index 0000000..12404ee --- /dev/null +++ b/app/templates/diary.html @@ -0,0 +1,7 @@ +📓 Konzerttagebuch
Deine privaten Erinnerungen an besuchte Konzerte.
{{ entry.artist }}
{{ entry.date }}{% if entry.venue %} · {{ entry.venue }}{% endif %}
🎵 Lieblingssong: {{ entry.favorite_song }}
{% endif %}{% if entry.notes %}{{ entry.notes }}
{% endif %}Noch keine Einträge. Markiere ein vergangenes Konzert als besucht und halte dort deine Erinnerung fest.
+ + Diese Namen werden einzeln für die Folgen-Funktion verwendet. +
{% if can_edit_details %}
++ + Der Veranstaltungstitel bleibt frei. Diese Namen werden einzeln für „Band folgen“ verwendet. +
+ @@ -895,6 +902,7 @@ flyerInput.addEventListener( // Originaldateien verarbeiten. const concertForm = flyerInput.form; const artistInput = document.getElementById("artist"); +const bandNamesInput = document.getElementById("band-names"); const startDatetimeInput = document.getElementById("start-datetime"); const duplicateWarning = document.getElementById("duplicate-warning"); const duplicateConfirmed = document.getElementById("duplicate-confirmed"); @@ -934,7 +942,7 @@ async function checkForDuplicates(showWarning = true) { if (duplicateRequestController) duplicateRequestController.abort(); duplicateRequestController = new AbortController(); try { - const params = new URLSearchParams({artist, start_date: startDate}); + const params = new URLSearchParams({artist, start_date: startDate, band_names: bandNamesInput.value}); const response = await fetch(`/api/concerts/duplicates?${params}`, { signal: duplicateRequestController.signal }); @@ -948,7 +956,7 @@ async function checkForDuplicates(showWarning = true) { } } -[artistInput, startDatetimeInput].forEach(input => input.addEventListener("input", () => { +[artistInput, bandNamesInput, startDatetimeInput].forEach(input => input.addEventListener("input", () => { duplicateConfirmed.value = "false"; concertForm.dataset.readyToSubmit = "false"; clearTimeout(duplicateTimeout); diff --git a/db/init/01_initial.sql b/db/init/01_initial.sql index 933700d..d4ab920 100644 --- a/db/init/01_initial.sql +++ b/db/init/01_initial.sql @@ -193,3 +193,37 @@ CREATE INDEX idx_concert_photos_concert CREATE INDEX idx_concert_attendance_concert ON concert_attendance(concert_id, status); + +CREATE TABLE followed_bands ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + band_key VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, band_key) +); + +CREATE TABLE followed_venues ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, venue_id) +); + +CREATE TABLE concert_diary ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), + favorite_song VARCHAR(255), + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, concert_id) +); + +CREATE TABLE concert_bands ( + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + band_key VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + position SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (concert_id, band_key) +); diff --git a/db/migrations/13_follows_and_diary.sql b/db/migrations/13_follows_and_diary.sql new file mode 100644 index 0000000..1d4c772 --- /dev/null +++ b/db/migrations/13_follows_and_diary.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS followed_bands ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + band_key VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, band_key) +); + +CREATE TABLE IF NOT EXISTS followed_venues ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + venue_id INTEGER NOT NULL REFERENCES venues(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, venue_id) +); + +CREATE TABLE IF NOT EXISTS concert_diary ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), + favorite_song VARCHAR(255), + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, concert_id) +); diff --git a/db/migrations/14_concert_bands.sql b/db/migrations/14_concert_bands.sql new file mode 100644 index 0000000..38b602c --- /dev/null +++ b/db/migrations/14_concert_bands.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS concert_bands ( + concert_id INTEGER NOT NULL REFERENCES concerts(id) ON DELETE CASCADE, + band_key VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + position SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (concert_id, band_key) +);