Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,7 @@ Tuist/.build
copilot-instructions.md
GEMINI.md

research/
research/
# Test secrets (service_role + PAT, local-only)
Tests/Support/TestSecrets.swift
.env.test
88 changes: 88 additions & 0 deletions Docs/migrations/001_disable_rls.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
-- ============================================================
-- Migration 001 — Disable RLS for academic project
-- Fecha: 2026-05-16
--
-- Contexto: DailyMath es proyecto de universidad. No requiere
-- aislamiento por usuario a nivel de fila. RLS solo agregaba
-- fricción para tests automatizados sin aportar valor a la rúbrica.
--
-- ⚠️ Esta migración hace TODAS las tablas accesibles para
-- lectura/escritura/borrado a cualquiera con la anon key del
-- proyecto. Aceptable solo en contexto académico (no público).
--
-- Ejecutar en: Supabase Dashboard → SQL Editor → Run.
-- Es idempotente (`if exists`), se puede correr varias veces.
-- ============================================================

-- ── 1. Drop policies ────────────────────────────────────────
drop policy if exists "Profiles readable by all" on public.profiles;
drop policy if exists "Users update own profile" on public.profiles;
drop policy if exists "Users insert own profile" on public.profiles;

drop policy if exists "Exercises readable" on public.exercises;
drop policy if exists "Users create exercises" on public.exercises;
drop policy if exists "Authors/mods update" on public.exercises;
drop policy if exists "Authors delete" on public.exercises;

drop policy if exists "Votes readable" on public.exercise_votes;
drop policy if exists "Users vote" on public.exercise_votes;
drop policy if exists "Users unvote" on public.exercise_votes;

drop policy if exists "Comments readable" on public.comments;
drop policy if exists "Users comment" on public.comments;
drop policy if exists "Authors delete comment" on public.comments;

drop policy if exists "Users see own flashcards" on public.flashcards;
drop policy if exists "Users create flashcards" on public.flashcards;
drop policy if exists "Users update flashcards" on public.flashcards;
drop policy if exists "Users delete flashcards" on public.flashcards;

drop policy if exists "Users see own notifications" on public.notifications;
drop policy if exists "System insert notifications" on public.notifications;
drop policy if exists "Users mark read" on public.notifications;

drop policy if exists "Anyone sees waiting duels" on public.duels;
drop policy if exists "Users create duels" on public.duels;
drop policy if exists "Players update duels" on public.duels;

drop policy if exists "Players see questions" on public.duel_questions;
drop policy if exists "Anyone insert questions" on public.duel_questions;
drop policy if exists "Anyone update questions" on public.duel_questions;

drop policy if exists "Tournaments readable" on public.tournaments;
drop policy if exists "Admins manage tournaments" on public.tournaments;

drop policy if exists "Participants readable" on public.tournament_participants;
drop policy if exists "Users join tournaments" on public.tournament_participants;
drop policy if exists "Users update their score" on public.tournament_participants;

drop policy if exists "Badges readable" on public.user_badges;
drop policy if exists "System grants badges" on public.user_badges;

-- ── 2. Disable row level security on every table ────────────
alter table public.profiles disable row level security;
alter table public.exercises disable row level security;
alter table public.exercise_votes disable row level security;
alter table public.comments disable row level security;
alter table public.flashcards disable row level security;
alter table public.notifications disable row level security;
alter table public.duels disable row level security;
alter table public.duel_questions disable row level security;
alter table public.tournaments disable row level security;
alter table public.tournament_participants disable row level security;
alter table public.user_badges disable row level security;

-- ── 3. Storage policies (mantener public read) ──────────────
-- Las storage policies necesitan permitir uploads/deletes sin auth
-- para que tests puedan subir imágenes de ejercicios test.
drop policy if exists "Auth users upload images" on storage.objects;
drop policy if exists "Authors delete images" on storage.objects;
create policy "Anyone upload exercise images" on storage.objects
for insert with check (bucket_id = 'exercise-images');
create policy "Anyone delete exercise images" on storage.objects
for delete using (bucket_id = 'exercise-images');

-- ── 4. Verificación ─────────────────────────────────────────
-- Después de correr, deberías poder hacer:
-- select * from public.profiles limit 1;
-- con la anon key y obtener filas sin login.
22 changes: 22 additions & 0 deletions Docs/migrations/002_moderators_update_profiles.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- ============================================================
-- Migration 002 — Moderators may update any profile (fix F-03)
-- Fecha: 2026-05-16
--
-- Context: F-03 detectó que `awardPointsTo` desde el moderator
-- al aprobar un ejercicio NO actualiza los puntos del autor porque
-- la policy "Users update own profile" solo permite actualizar el
-- propio. Esta policy agrega permiso para moderators.
--
-- Ejecutar en: Supabase Dashboard → SQL Editor → Run.
-- Idempotente.
-- ============================================================

drop policy if exists "Moderators update any profile" on public.profiles;

create policy "Moderators update any profile" on public.profiles
for update using (
exists (
select 1 from public.profiles p
where p.id = auth.uid() and p.is_moderator = true
)
);
45 changes: 45 additions & 0 deletions Docs/migrations/003_votes_count_trigger.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
-- ============================================================
-- Migration 003 — Trigger que mantiene votes_count consistente (fix F-04)
-- Fecha: 2026-05-16
--
-- Context: F-04 detectó que el contador `exercises.votes_count`
-- nunca se incrementa al votar. Este trigger lo mantiene en sync
-- con las filas de `exercise_votes`.
--
-- Ejecutar en: Supabase Dashboard → SQL Editor → Run.
-- Idempotente.
-- ============================================================

create or replace function public.bump_votes_count()
returns trigger
language plpgsql
as $$
begin
if TG_OP = 'INSERT' then
update public.exercises
set votes_count = votes_count + 1
where id = NEW.exercise_id;
elsif TG_OP = 'DELETE' then
update public.exercises
set votes_count = greatest(0, votes_count - 1)
where id = OLD.exercise_id;
end if;
return null;
end;
$$;

drop trigger if exists trg_votes_count_ins on public.exercise_votes;
create trigger trg_votes_count_ins
after insert on public.exercise_votes
for each row execute function public.bump_votes_count();

drop trigger if exists trg_votes_count_del on public.exercise_votes;
create trigger trg_votes_count_del
after delete on public.exercise_votes
for each row execute function public.bump_votes_count();

-- Backfill: corregir cuentas previas que pudieron quedar desincronizadas.
update public.exercises e
set votes_count = coalesce((
select count(*) from public.exercise_votes v where v.exercise_id = e.id
), 0);
30 changes: 30 additions & 0 deletions Docs/migrations/004_duels_join_policy.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- ============================================================
-- Migration 004 — Permitir join a duelos waiting (fix F-07)
-- Fecha: 2026-05-16
--
-- Context: F-07 detectó que `joinDuel` falla con PGRST116 porque
-- la policy "Players update duels" solo permite UPDATE si el user
-- ya es player1 o player2 — cuando recién se UNE, no es ninguno.
-- Esta migración separa la lógica: una policy para in-game updates
-- (score, finish, etc.) y otra para el momento del join.
--
-- Ejecutar en: Supabase Dashboard → SQL Editor → Run.
-- Idempotente.
-- ============================================================

-- Reemplazar la policy antigua
drop policy if exists "Players update duels" on public.duels;
drop policy if exists "Players update active duels" on public.duels;
drop policy if exists "Anyone joins waiting duels" on public.duels;

-- Updates "normales" de jugadores ya en el duelo (score, currentQuestion, finish)
create policy "Players update active duels" on public.duels
for update using (
auth.uid() = player1_id or auth.uid() = player2_id
);

-- Cualquier user autenticado puede unirse a un duelo waiting con player2 vacío
create policy "Anyone joins waiting duels" on public.duels
for update using (
status = 'waiting' and player2_id is null and auth.uid() is not null
);
17 changes: 17 additions & 0 deletions Docs/migrations/005_profiles_delete_policy.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- ============================================================
-- Migration 005 — Permitir borrar el propio profile (fix F-09)
-- Fecha: 2026-05-16
--
-- Context: F-09 detectó que `deleteAccount` no borra la fila de
-- `profiles` porque no había ninguna policy DELETE definida en
-- esa tabla (RLS default = deny). Esta agrega permiso para que
-- cada user borre su propio profile.
--
-- Ejecutar en: Supabase Dashboard → SQL Editor → Run.
-- Idempotente.
-- ============================================================

drop policy if exists "Users delete own profile" on public.profiles;

create policy "Users delete own profile" on public.profiles
for delete using (auth.uid() = id);
3 changes: 3 additions & 0 deletions Modules/Core/Theme/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ enum AppConstants {
static let comment = 2
static let voteReceived = 1
static let dailyReviewSession = 3
static let duelWon = 20
static let duelLost = 5
static let tournamentWon = 50
}

/// Niveles de usuario
Expand Down
2 changes: 1 addition & 1 deletion Modules/Core/Utils/EmailHistoryStore.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Foundation

struct EmailHistoryStore {
enum EmailHistoryStore {
private static let key = "dm.auth.email_history"

static func getHistory() -> [String] {
Expand Down
4 changes: 3 additions & 1 deletion Modules/Core/Wrappers/OTPServiceProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ protocol OTPServiceProtocol {
struct OTPServiceStub: OTPServiceProtocol {
func sendMagicLink(to email: String) async throws {
try await Task.sleep(nanoseconds: 1_000_000_000)
print("[OTP Stub] Magic link enviado a \(email)")
#if DEBUG
print("[OTP Stub] Magic link enviado a \(email)")
#endif
}

func verifyToken(_: String) async throws -> UserProfile {
Expand Down
8 changes: 8 additions & 0 deletions Modules/Data/Local/LocalDuelRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ final class LocalDuelRepository: ObservableObject, DuelRepository {
Task {}
}

func subscribeToDuel(_: UUID, onUpdate _: @escaping (Duel) -> Void) -> Task<Void, Never> {
Task {}
}

func fetchDuel(_: UUID) async -> Result<Duel, DMError> {
return .failure(.notFound)
}

func fetchActiveTournaments() async -> Result<[Tournament], DMError> {
return .success([])
}
Expand Down
4 changes: 3 additions & 1 deletion Modules/Data/Local/LocalStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ final class LocalStore<Entity: Codable & Identifiable> where Entity.ID: Hashable
let data = try encoder.encode(cache)
try data.write(to: fileURL, options: .atomic)
} catch {
print("[LocalStore] persist error: \(error)")
#if DEBUG
print("[LocalStore] persist error: \(error)")
#endif
}
}

Expand Down
3 changes: 2 additions & 1 deletion Modules/Data/Remote/SupabaseAuthService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ final class SupabaseAuthService: ObservableObject, AuthRepository {
let errorMsg = error.localizedDescription
errorMessage = errorMsg
if errorMsg.localizedCaseInsensitiveContains("email not confirmed") ||
errorMsg.localizedCaseInsensitiveContains("confirm") {
errorMsg.localizedCaseInsensitiveContains("confirm")
{
return .failure(.emailConfirmationRequired("Tu correo no está confirmado. Por favor ingresa el código de confirmación."))
}
return .failure(.unknown(errorMsg))
Expand Down
54 changes: 28 additions & 26 deletions Modules/Data/Remote/SupabaseCommunityRepository.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ final class SupabaseCommunityRepository: ObservableObject, CommunityRepository {
"content": .string(content),
]
try await supabase.from("comments").insert(dto).execute()
_ = try? await notifyCommentOnExercise(exerciseId: exerciseId, commenterId: userId)
// Notificación al autor del exercise: la dispara el trigger SQL
// `trg_comment_notify` en `public.comments` (server-side, SECURITY DEFINER).
awardPoints(AppConstants.ReputationPoints.comment)
return .success(())
} catch {
Expand Down Expand Up @@ -128,16 +129,34 @@ final class SupabaseCommunityRepository: ObservableObject, CommunityRepository {
onNew: @escaping (AppNotification) -> Void
) -> Task<Void, Never> {
Task {
let channel = supabase.channel("notifications-\(userId.uuidString)")
// Postgres normaliza UUIDs a lowercase. El filter realtime es case-sensitive,
// así que con uuidString uppercase (default de Swift) los eventos nunca matchean.
let userIdString = userId.uuidString.lowercased()
let channel = supabase.channel("notifications-\(userIdString)")
let stream = channel.postgresChange(
InsertAction.self,
schema: "public",
table: "notifications",
filter: .eq("user_id", value: userId.uuidString)
filter: .eq("user_id", value: userIdString)
)
_ = try? await channel.subscribeWithError()
// Postgres envía timestamps ISO8601 con fracciones de segundo y "+00:00".
// El decoder default no los parsea; usamos uno custom que tolera ambos formatos.
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { dec in
let string = try dec.singleValueContainer().decode(String.self)
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: string) { return date }
formatter.formatOptions = [.withInternetDateTime]
if let date = formatter.date(from: string) { return date }
throw try DecodingError.dataCorruptedError(
in: dec.singleValueContainer(),
debugDescription: "Invalid date: \(string)"
)
}
for await change in stream {
if let notification: AppNotification = try? change.decodeRecord(decoder: JSONDecoder()) {
if let notification: AppNotification = try? change.decodeRecord(decoder: decoder) {
onNew(notification)
}
}
Expand All @@ -150,26 +169,9 @@ final class SupabaseCommunityRepository: ObservableObject, CommunityRepository {
auth.updateCurrentProfile { $0.points += amount; $0.reputation += amount }
}

private func notifyCommentOnExercise(exerciseId: UUID, commenterId: UUID) async throws {
let commenterName = auth.currentUser?.displayName ?? auth.currentUser?.username ?? "Alguien"

let exercise: Exercise? = try? await supabase
.from("exercises")
.select()
.eq("id", value: exerciseId.uuidString)
.single()
.execute()
.value

guard let exercise, exercise.authorId != commenterId else { return }

let dto: [String: AnyJSON] = [
"user_id": .string(exercise.authorId.uuidString),
"type": .string("comment"),
"title": .string("\(commenterName) comentó tu ejercicio"),
"body": .string(exercise.title),
"reference_id": .string(exerciseId.uuidString),
]
_ = try? await supabase.from("notifications").insert(dto).execute()
}
// F-05 resuelto en Sprint 01 vía trigger SQL `trg_comment_notify` en `public.comments`:
// cuando se inserta un comment, el trigger AFTER INSERT (SECURITY DEFINER) crea la
// notificación para el autor del exercise automáticamente. El cliente Swift no
// necesita hacer nada extra. Anteriormente había un bug profundo del SDK donde
// INSERTs a `notifications` desde el commenter desaparecían post-commit.
}
Loading
Loading