MeetyyAPI
Documentation / Implementation Status

Meetyy API β€” Implementation Deliverables

Living implementation tracker mapped against the BRD in doc.md. Update this file as requirements are implemented. It reflects actual code state, not intent. Endpoint reference for every module: browse /docs/section/api, or the docs/*-api.md files directly. New docs must be registered in DocsController::registry() to be served.

Last updated: 2026-07-22 Test baseline: php artisan test β†’ 706 passed / 0 failed (verified 2026-07-22). This cycle added 35 notification-module tests (Phase 10 L1–L4) on top of the 671 recorded after the community share/report additions.


Roadmap Status Overview

Phase Scope Status
1 Foundation (auth, profiles, onboarding, approval) 🟒 Complete
2 Availability Engine 🟒 Complete
3 Session Booking (free + paid via fake checkout) 🟒 Complete (M1–M7)
4 Messaging & Chat (1:1 threads, files, in-chat appointment booking) 🟒 Complete (M1–M5; real-time delivery now live via Phase 10 Reverb)
5 Communities 🟒 Complete (feed, XP/levels, fake-checkout subscriptions, course gating; events calendar deferred)
6 Courses 🟒 Complete (community gating now activated by Phase 5)
β€” Public Feed (unified feed, connections, suggested profiles, posts, reactions, comments, shares, polls, hashtags, reporting) 🟒 Complete (out-of-BRD social module; report moderation deferred)
7 Platform Subscriptions πŸ”΄ Not started
8 AI Mentor πŸ”΄ Not started
9 AI Helper πŸ”΄ Not started
10 Notifications & Polish 🟑 In progress β€” notification module L1–L4 shipped (engine, queued delivery, Reverb websockets, full BRD Β§12 catalog); mail branding (L5), FCM push, preference matrix, review system, admin dashboard pending
11 Scale & Hardening πŸ”΄ Not started

Legend: 🟒 complete Β· 🟑 partial Β· πŸ”΄ not started


Phase 1 β€” Foundation 🟒

Deliverable Status Notes
users, oauth_accounts 🟒 Email/password + OAuth (Socialite)
Email verification (SHA-256 token, 60-min expiry) 🟒 §3.4
2FA (email / SMS / TOTP), backup codes, trusted devices, passkeys 🟒 Β§3.5 β€” multi-method model (default_two_factor_method + enabled_two_factor_methods); tests reconciled
mentor_profiles, mentee_profiles 🟒 Auto mentee profile on registration/mentor approval
Onboarding questionnaire 🟒 §3.3
Expertise areas + skills (nested) 🟒 Registration + mentee profile use expertise_areas: [{id, skills:[…]}]
Mentor certifications 🟒 Created during registration
Admin approval flow (approve/reject/suspend) 🟒 AdminMentorController + MentorManagementService; 17 tests pass
Mentor verification-document upload 🟒 Implemented this cycle β€” upload/list/download/delete on a private disk. Β§3.3 / Β§4.2

Phase 1 β€” resolved this cycle

  1. Verification-document upload β€” new VerificationDocumentController (index/store/download/destroy), VerificationDocumentService (private local disk), StoreVerificationDocumentRequest, VerificationDocumentResource, VerificationDocumentType + VerificationDocumentStatus enums. 9 tests.
  2. 2FA hardening β€” enable() now validates the specific requested method is configured; disable() is a full reset (wipes backup codes, trusted devices, challenge codes, method state). Enable response now returns method + method_label.
  3. Mentee profile β€” show returns 404 (not 500) when no profile; update firstOrCreates the profile instead of 500-ing on a null one.

Phase 2 β€” Availability Engine 🟒

Deliverable Status Notes
mentor_availability_rules (regular_weekly / recurring / one_time) 🟒 RRULE via recurr library
mentor_off_days (full-day + partial-hour) 🟒
availability_slots (precomputed) 🟒 UTC storage, unique (mentor, session_type, starts_at)
slot_holds + 10-min holds 🟒 SELECT … FOR UPDATE, grouped by booking_attempt_id
Nightly generation cron 🟒 slots:generate daily, 90-day horizon
Expired-hold release cron 🟒 slots:release-expired-holds everyMinute
Timezone handling (mentor TZ β†’ UTC) 🟒 Inline in SlotGenerationService
Regeneration on edit β€” rules / session types 🟒 Dispatches GenerateMentorSlotsJob
Regeneration on edit β€” off-days / vacation 🟒 Added this cycle (Part 1). Only available slots mutated; booked/held preserved.
Slot-invalidation priority (vacation > recurring > weekly) 🟒 §2.6 priorities 1/3/4
Hold status reset (held β†’ available) 🟒 Test-locked both directions (expired resets, live preserved)
API documentation 🟒 Added this cycle β€” docs/availability-api.md; 16 endpoints (rules, off-days, vacation, public + mentor slots, holds)

Phase 2 notes & deferrals

  • Notifications on affected bookings β€” Β§2.1 / Β§2.6 require notifying the mentor when a vacation/edit hits already-booked slots. Slots are correctly preserved, and since Phase 10 L4 the mentor is notified via Mentor\BookedSlotsAffectedNotification (in-app + email). Delivered.
  • slot_holds rows are not deleted on expiry β€” intentional. Availability is driven solely by availability_slots.status/held_until; hold rows are inert audit records.

Phase 2 β€” API surface inconsistencies (surfaced while documenting)

Behaviour is correct but the contract is uneven. Documented as-is in docs/availability-api.md so clients aren't surprised; each is a candidate cleanup, none is a blocker.

Item Current behaviour Note
Key casing Rules + off-days return camelCase; slots + session types return snake_case Cross-module inconsistency; changing it is a breaking client change
PUT /availability-rules/{id} on unknown id returned 500 Fixed this cycle β€” show / update / activate / destroy now catch ModelNotFoundException β†’ 404; genuine faults still 500 show previously mapped all throwables to 404, masking real errors. 8 tests lock both the missing-id and cross-mentor cases
from / until on slot reads Unvalidated, Carbon::parsed β†’ malformed input yields 500 not 422 No Form Request on the slot read endpoints
is_active filter on rules index when($request->input('is_active')) β€” is_active=0/false is falsy, so the filter is skipped Inactive rules can't be listed via the filter
GET /public/mentors/{id}/session-types Returns inactive session types β€” ->where('is_active', true) is commented out in SessionTypeService Verify intent before re-enabling; may be deliberate for profile display
POST /mentor/slots/hold Mounted under the mentor prefix but authorizes on $request->user() β€” it is a mentee action Routing artifact, not an access rule
POST /availability-rules/bulk Not wrapped in a transaction β€” a mid-loop write failure leaves earlier rules persisted Validation failures still fail closed (422, nothing written)
app\Http\Resources\Mentor\AvailabilitySlotResource Dead code and broken β€” reads availability_rule_id, but the model attribute is rule_id Zero references; every call site uses the Public\Mentor variant. Safe to delete

Deviations from BRD

Item Decision Rationale
Priority-2 one-time custom overrides (Β§2.6) Removed MentorAvailabilityOverwrite module Built under confusion between "override" (follow-up interval) and availability blocking. BRD leaves Β§6.2 storage unspecified; feature can be re-added cleanly under Phase 3 if needed. override_blocked status retained in SlotStatus enum.
Follow-up interval mentor override (Β§2.5) Implemented as FollowUpService Resolves interval/window from session_types.follow_up_interval_days / follow_up_flexibility_days. Built but not yet wired β€” consumed by Phase 3 booking flow (Β§7.2 step 3).
session_model naming RegularWeekly / Recurring / OneTime Matches BRD Β§13.2 (`regular_weekly

Known Failing Tests

None. Full suite green (see the test baseline at the top of this file). The 19 previously-failing tests (2FA Γ—15, Mentee profile Γ—4) were reconciled to the current API contract, and the two real bugs behind them (2FA enable/disable behavior, mentee-profile 500/404) were fixed.


Phase 3 β€” Session Booking 🟒

Scope decisions (locked): free + paid-session lifecycle (real Stripe escrow/refunds still deferred β€” paid sessions settle through a fake checkout page); ad-hoc follow-ups deferred to Phase 4; domain events dispatched at each transition (delivery now wired β€” Phase 10 L4 registered listeners for all five events).

Free-session lifecycle: REQUESTED β†’ CONFIRMED β†’ COMPLETED, with CANCELLED from any active state. is_free bypasses PAID/REFUNDED (Β§2.8); slots become booked + follow-ups reserved at CONFIRMED.

Paid-session lifecycle: REQUESTED β†’ CONFIRMED β†’ PAID β†’ COMPLETED, CANCELLED from any active state. At CONFIRMED the slots stay held (hold extended to the 24h payment deadline, Β§5.3); at PAID they become booked. Payment is faked: GET /mentee/bookings/{id}/payment-link returns a signed URL to a checkout page (/payments/checkout/{booking}) with Pay / Cancel buttons β€” Pay β‡’ PAID, Cancel β‡’ CANCELLED. No login required (the signature authorises the browser).

Milestones

Milestone Scope Status
M1 Data layer: BookingStatus/FollowUpStatus enums, session_bookings + session_followups migrations/models/factories, reverse relations 🟒 Done (3 schema tests)
M2 Follow-up slot presentation (GET /mentee/bookings/follow-up-slots, wires FollowUpService) + booking creation (POST /mentee/bookings, hold β†’ REQUESTED, BookingRequested event) 🟒 Done (8 tests)
M3 Lifecycle state machine: mentor confirm/decline/cancel + mentee cancel; free confirm books slots, cancel releases them; BookingConfirmed/BookingCancelled events; free-only creation guard 🟒 Done (10 tests)
M4 Completion: bookings:auto-complete (every 30 min) β€” confirmed bookings + reserved follow-ups past their end β†’ completed, total_sessions credited, SessionCompleted event 🟒 Done (5 tests)
M5 Scheduled: bookings:expire-unconfirmed (hourly) β€” REQUESTED bookings older than 24h β†’ CANCELLED (system, null actor), slots released, follow-ups cancelled, BookingCancelled event 🟒 Done (5 tests)
M6 Read endpoints: GET /mentee/bookings (own, status filter, paginated) + GET /mentor/bookings dashboard (§15.3 tabs via status filter); single-booking detail GET /mentee/bookings/{id} + GET /mentor/bookings/{id} (owner-scoped, embeds the other party + session type) 🟒 Done (14 tests)
M7 Paid-session fake payment: paid types now bookable (free-only guard removed); confirm holds slots to the 24h payment deadline; GET /mentee/bookings/{id}/payment-link β†’ signed checkout URL; web PaymentController checkout/pay/cancel (signed, no login); pay β†’ PAID + books slots + BookingPaid event; auto-complete credits paid bookings from PAID 🟒 Done (11 tests)

Folder structure: booking files live under the feature folder that uses them β€” mentee-initiated flows (create booking, follow-up presentation) under Mentee/ (Http/Controllers/Api/V1/Mentee, Services/Mentee, Http/Requests/Mentee, Http/Resources/Mentee, routes/api/v1/mentee); mentor management under Mentor/. Shared domain types stay put: models flat in app/Models/ (like SessionType/AvailabilitySlot), enums in app/Enums/Booking, events in app/Events/Booking.

Deferred (future cycles): real payment gateway β€” payments + refunds tables, Stripe escrow/capture/payout, refund policy engine (Β§8), and a payment-deadline expiry sweep for confirmed-but-unpaid bookings. session_bookings.payment_id is a nullable unconstrained column reserved for this. The current PAID transition is a fake checkout stand-in (no gateway, no charge).

Design note (resolved in M7): the 10-min selection hold (Β§6.3) is extended to the 24h payment deadline (Β§5.3) when a paid booking is confirmed, so the slot stays reserved through checkout.


Phase 4 β€” Messaging & Chat 🟒

Scope decisions (locked): direct 1:1 messaging (Β§11) with a request/accept handshake; the in-chat appointment flow (Β§11.3, Β§15.4) reuses SessionBooking + the availability/booking/fake-payment services β€” no separate custom_appointments entity; delivery is the synchronous database notification channel (real-time broadcasting / push deferred to Phase 10). Threads use a group-capable chat_threads + chat_participants schema, but only the direct surface is wired. Chat is actor-agnostic β€” a single Api/V1/Chat/ folder + top-level routes/api/chat.php, mirroring the Feed module (a thread has no fixed mentor/mentee role). Full endpoint reference: docs/messaging-api.md.

AI-readiness: human chat and the future AI Mentor conversation tables are kept separate but share conventions β€” the message-level type discriminator + metadata JSON escape hatch, thread-level last_message_at/message_count, and the app-wide NotificationType enum (AI adds ai.* cases later). No rework needed when Phase 8 arrives.

Folder structure: models under app/Models/Chat/ (Thread/Participant/Message, explicit $table); enums under app/Enums/Chat/ (ChatThreadStatus, ChatMessageType); services under app/Services/Chat/; requests/resources under App\Http\{Requests,Resources}\Chat; controllers under Api/V1/Chat; notifications under App\Notifications\Chat; routes routes/api/chat.php β†’ routes/api/v1/chat/{threads,messages,appointments}.php. Meeting-link generation lives in app/Services/Booking/MeetingLinkService.php.

Milestone Scope Status
M1 Data layer β€” chat_threads + chat_participants + chat_messages migrations, models, ChatThreadStatus/ChatMessageType enums, factories; canonical direct_key dedupe 🟒 Done (5 schema tests)
M2 Thread handshake (§11.1) + text messaging + per-participant unread cursor + chat.message_request/chat.new_message notifications 🟒 Done (15 tests)
M3 File attachments β€” medialibrary attachments collection + File message type 🟒 Done (3 tests)
M4 In-chat appointments (Β§11.3, Β§15.4) β€” shareable-slots, calendar_share β†’ appointment_request (reuses Mentee\BookingService), mentor accept + Set Price + signed payment link (reuses BookingManagementService/PaymentController), decline; meeting-link generation (closes a latent Phase 3 gap β€” session_bookings.meeting_url was never populated); booking-event listener posts system messages into the thread; typed messages carry their availability resolved live (shared_slots / slot / session_type, two queries per page) so clients render slot cards without a second round-trip 🟒 Done (8 tests)
M5 docs/messaging-api.md (full endpoint reference) + this tracker 🟒 Done β€” the docs/*-api.md set is the maintained reference; the root Postman collections are stale project-wide (no phase since June updates them) and were left untouched

Phase 4 β€” deferred / cross-phase

Item Present Unblocks with
Real-time delivery (websockets/Reverb) + push channel 🟒 Reverb delivered (Phase 10 L3) β€” queued database channel + live broadcast; FCM push still deferred (needs mobile app) Push: future mobile cycle
Group-thread endpoints/UX chat_participants schema is N-ready; only 1:1 wired Future chat cycle
Blocking / mute between users β€” Future chat cycle
Real video-provider meeting links (Zoom/Meet/Jitsi) MeetingLinkService emits a placeholder URL + wiring seam Provider integration (no schema change)

Phase 5 β€” Communities 🟒

Scope decisions (locked): full Skool-style community module minus the events calendar; one join endpoint and one leave endpoint for free and paid β€” every join issues a subscription row (free ones zero-price and active on the spot) and leave cancels it, so both pricing models share a single code path; leaving revokes access immediately with no grace window until ends_at (revisit when a real gateway lands); paid subscriptions settle through a fake checkout page mirroring the booking flow (no gateway); in-app notifications only (database channel, synchronous). Participation lives on the mentee surface (every user has a mentee profile), membership keyed by user_id; owner management on the mentor surface. Full endpoint reference: docs/community-api.md.

Folder structure: models under app/Models/Community/; enums under app/Enums/Community/; services under app/Services/Community/; controllers split by actor (Api/V1/{Mentor,Mentee,Public}); requests/resources under App\Http\{Requests,Resources}\Community; routes under routes/api/v1/{mentor,mentee,public}/communities.php (+ signed web checkout in routes/web.php).

Deliverable Status Notes
Data layer β€” communities, community_members, community_level_thresholds, community_subscriptions, community_posts (reaction_count + hot_score + type/shared_post_id/share_count), community_post_comments (reaction_count), community_xp_events, notifications 🟒 18 migrations (incl. course FK ALTERs); UUIDs, enum casts, factories; post/comment reactions reuse the polymorphic feed_reactions, reports reuse feed_reports; community_post_likes dropped
Enums β€” CommunityStatus, CommunityPricingType, CommunityBillingInterval, CommunityMemberRole, CommunityMemberStatus (incl. left), CommunitySubscriptionStatus, CommunityXpAction + NotificationType cases 🟒 Β§9, Β§17; left = self-exit (may rejoin) vs removed = moderation vs banned = final
Owner management β€” community CRUD + publish/unpublish/archive, cover/icon media, pricing change (grandfathering + 30-day notice) 🟒 Owner auto-seeded as first member; ownership by query scoping (404)
Membership β€” unified join / leave (free or paid), members list, owner removal, my-memberships 🟒 A single POST …/join and DELETE …/leave cover both pricing models; leave cancels any live subscription in the same transaction; member_count kept in sync; owner cannot be removed or leave; banned members cannot rejoin
Feed β€” posts / comments / typed reactions (6 types, per-type breakdown, my_reaction) on posts & comments, hot-score ranking (?sort=top default / ?sort=recent, pinned-first), denormalized counters, attachments, owner pin & moderation 🟒 Member-gated (403); reuses the public-feed reaction system; per-post CommunityPostRankingService + community:recompute-hot-scores sweep (share-weighted, same formula as the public feed); replaces the former boolean like
Sharing β€” share/repost within the same community, optional quote body, shared_post nested in responses 🟒 Mirrors the public-feed share: flattens to the root post, increments share_count, feeds the hot score (Γ—3); no XP (farming guard); no cross-community/public re-share
Reporting β€” polymorphic report of post or comment, one per user (duplicate 409) 🟒 Member-gated; reuses feed_reports + FeedReportService (capture-only, pending status); moderation surface deferred with the feed's
Gamification β€” XP ledger (daily caps + once-per-source dedupe), per-community level thresholds, level derivation, me standing 🟒 Β§9.2; UTC daily buckets; lockForUpdate on award
Subscriptions β€” 4 pricing models, signed fake checkout, one-time = permanent / subscription = renewal window, grandfathered price_snapshot 🟒 Β§17; mirrors the booking PaymentController stub. Free communities issue a zero-price active subscription, so free and paid settle through one code path
Course gating β€” community-context resolveFor (specific_community β†’ community_member β†’ public within a community), community_only enrolment, unlock_at_level module locking, gated catalog endpoint 🟒 Β§9.3, Β§10.2–10.3; public/anonymous resolution unchanged. Multi-community: see the community_course row below
XP hooks into course/booking β€” lesson (+20) / course (+100) completion, session booked (+30) 🟒 Wired in CourseProgressService + BookingManagementService
Notifications β€” owner post β†’ members, comment β†’ author, pricing change β†’ members (30-day notice) + mentee read endpoints 🟒 Database channel, synchronous
Public access β€” anonymous community browse/detail routes 🟒 Api/V1/Public community controllers
Tests 🟒 93 feature tests (schema 5 · management 7 · membership 15 · feed 10 · comments 11 · video posts 2 · share 5 · report 5 · XP 7 · subscriptions 6 · course gating 9 · notifications 5 · public show 6)

Phase 5 β€” deferred / cross-phase

Item Present Unblocks with
Events calendar + "attend event" XP (+50) β€” Future community cycle
Report moderation surface (list/resolve reports, hide/remove content) reports captured in feed_reports Future moderation cycle (shared with the public feed)
Real subscription billing / renewals / dunning community_subscriptions.payment_id (unconstrained) Payments module
Queued notification fan-out; email / push channels 🟒 Queued fan-out + Reverb broadcast delivered (Phase 10); branded email templates land with L5; push deferred Mail polish: Phase 10 L5

Phase 6 β€” Courses 🟒

Scope decisions (locked): the standalone course lifecycle (author β†’ discover β†’ enroll β†’ learn) is complete, and community-linked behaviour is now activated by Phase 5 (membership-aware pricing, community_only serving, level-gated modules, lesson/course XP). Two evaluation types are supported: quiz (auto-scored, hard time-limit cutoff) and assignment (mentor-graded file submission with total/passing marks; a timed submission past the limit is accepted but flagged late with a required note). Real paid settlement remains deferred to the payments module. Full endpoint reference: docs/course-api.md.

Folder structure: models under app/Models/Course/ (namespaced App\Models\Course); factories under database/factories/Course/; enums under app/Enums/Course; services under app/Services/Course; requests/resources under App\Http\{Requests,Resources}\Course; controllers split by actor (Api/V1/Mentor, Api/V1/Mentee, Api/V1/Public); routes under routes/api/v1/{mentor,mentee,public}/courses.php.

Deliverable Status Notes
Data layer β€” courses, course_pricing, course_modules, course_lessons, course_enrollments, course_lesson_progress 🟒 UUIDs, enum casts, factories; deferred columns nullable/unconstrained
Enums β€” CourseStatus, CourseVisibility, LessonType, PricingAudience, EnrollmentStatus, LessonProgressStatus 🟒 Β§10.1–10.3; LessonType now includes assignment, LessonProgressStatus now includes submitted
Mentor authoring β€” course CRUD + publish/unpublish/archive, thumbnail media 🟒 Unique-slug generation; ownership by query scoping (404)
Mentor authoring β€” modules & lessons CRUD + reorder 🟒 Auto-position; per-type content validation; PDF attachment media; course totals recomputed
Mentor authoring β€” assignment lessons (attachments, total/passing marks, timer) 🟒 Added this cycle β€” assignment lesson type: mentor uploads assignment_attachments, sets assignment_payload (total_marks/passing_marks/instructions) + time_limit_minutes
Mentor grading β€” list assignment submissions + grade (award marks, pass/fail rollup) 🟒 Added this cycle β€” GET …/lessons/{lesson}/submissions, PATCH …/submissions/{submission}/grade; pass (awarded β‰₯ passing) completes the lesson, fail β†’ in_progress (resubmit)
Mentor authoring β€” per-audience pricing CRUD 🟒 (course, audience, community) unique; public/community-scoped validation
Discovery β€” browse (search/filter/sort, paginated) + slug detail 🟒 Published+public only; preview-only content gating for non-enrolled
Discovery β€” enriched detail (summary, learning outcomes, resolved pricing, module list w/ lesson count + total duration, is_enrolled status) 🟒 Public course/module show returns enrollment-aware fields
Enrollment + audience pricing resolution 🟒 Free/paid, duplicate + visibility guards, price snapshot; public audience resolved
Learning + progress β€” content access, mark-complete, quiz scoring, video position, rollup/auto-complete 🟒 403 without enrollment; quiz answer keys stripped from learner content
Learning β€” single-lesson-with-progress endpoint for enrolled learners 🟒 GET /mentee/courses/{course}/lessons/{lesson} embeds the learner's per-lesson progress
Learning β€” timed lessons + assignment submission 🟒 Added this cycle β€” POST …/lessons/{lesson}/start (server-side timer); POST …/lessons/{lesson}/assignment (file + note). Quiz = hard cutoff past the limit; assignment = accepted-but-flagged is_late (note required)
Tests 🟒 119 feature tests (authoring 22 · discovery 12 · pricing 7 · enrollment 8 · progress 16 · assignment/timer 15 · public-show 4 · community link 15 · community pivot 20)

Phase 6 β€” activated by Phase 5 (Communities)

Item Status
community_course link table 🟒 Built β€” a course may be carried by several communities; replaces the singular courses.community_id, which has been dropped
community_only served to members 🟒 Activated β€” enrolment gated by membership of any carrying community
Level-gated modules (unlock_at_level enforcement) 🟒 Activated β€” resolved against the community the learner enrolled through (course_enrollments.community_id); rejected on courses no community carries
community_member / specific_community pricing resolution 🟒 Activated β€” resolveFor resolves in a community's context, so a shared course quotes each community its own price
XP awards on lesson/course completion 🟒 Activated β€” +20 lesson, +100 course, in the enrolling community only
Orphaned community-only courses 🟒 Handled β€” deleting the last carrying community downgrades the course to unlisted rather than leaving it visible to nobody

Phase 6 β€” still deferred / cross-phase

Item Present Unblocks with
Premium-exclusive courses (is_exclusive_to_premium) column Phase 7 β€” Platform Subscriptions
Real paid settlement / escrow (course_enrollments.payment_id) column Payments module
"New lesson published" notification (Β§12) 🟒 Delivered (Phase 10 L4 β€” Course\LessonPublishedNotification) β€”
Assignment submitted / graded notifications 🟒 Delivered (Phase 10 L4 β€” Course\AssignmentSubmitted/GradedNotification) β€”
Reviews / ratings (feeds average_rating) column Standalone-buildable; not yet built
Video content protection (signed URLs, watermarking, DRM) external video_url only Deliberate deferral β€” evaluated 2026-07-21, see below

Phase 6 β€” video delivery & content protection (evaluated 2026-07-21, deferred)

Course video is external-URL only: course_lessons.video_provider + video_url hold a mentor-supplied link (YouTube / Vimeo / Google Drive / direct file), as the original migration states β€” "External-URL video; no file upload in v1." There is no upload path, no streaming, no signed URL and no video media collection; LearnLessonResource returns the stored string verbatim to any enrolled learner. PublicCourseLessonResource is the only gate β€” it omits the field for non-preview lessons.

Protection against screen recording was scoped and deliberately not built. Findings:

Finding Detail
Screen recording cannot be prevented Only hardware DRM (Widevine L1 / FairPlay / PlayReady SL3000) blocks OS-level capture, and it silently degrades to software DRM on Firefox, Linux and rooted Android β€” where capture works normally. A phone camera defeats all of it. Not solvable in the API at any effort level.
The real exposure is link sharing, not recording video_url is permanent, unauthenticated and freely shareable β€” one paying learner can distribute it and any number of non-enrolled users watch. This is solvable and is the higher-value fix.
Signed URLs are impossible on third-party links Expiring, per-user URLs require controlling the server that sends the bytes. YouTube and Drive links cannot be signed, expired or revoked; the video id is necessarily client-visible. Protection requires moving lessons onto controlled hosting.
Google Drive is unsuitable as a video origin regardless Enforces per-file download quotas β€” popular lessons begin failing for learners independently of any security concern.

Decision: keep external URLs, build nothing. Accepted consequence β€” course video is shareable and non-revocable. Revisit if link sharing shows measurable revenue impact.

Scoped plan if reopened (four phases, ~10 files; nothing was written):

Phase Scope
A App\Enums\Course\VideoSource (External / Protected); migration adding video_source + video_asset_id, defaulting to External so existing rows are untouched and protection is opt-in per lesson
B CourseLessonService::syncVideo() mirroring the existing syncAttachment() pattern; video_source + asset validation in Store/UpdateLessonRequest
C Driver mints a short-TTL, per-user playback URL; LearnLessonResource returns it instead of the raw column. Only this phase differs by host
D Tests reusing the existing createCompletedVideoUpload() helper in tests/Pest.php

Recommended host if reopened: Cloudflare R2. S3-compatible, so it works through the s3 disk already defined in config/filesystems.php, and Storage::temporaryUrl() provides signed expiring URLs natively β€” reducing phase C to roughly one class. 10 GB free and zero egress fees, which matters because delivery bandwidth (not storage) dominates video cost: ~10 h of video served to ~500 learners is ~$50/mo on a metered provider versus ~$0 on R2. Tradeoff β€” R2 does no transcoding, so there is no adaptive bitrate and learners on weak mobile connections buffer rather than dropping quality. Pricing verified 2026-07-21; re-check before committing.

Watermarking + playback audit logging (learner identity overlaid on the player, each mint logged for forensics) was also declined. It works over any player including third-party iframes, so it remains available as a cheap standalone option that does not require moving hosting.

Adjacent gap surfaced while scoping: the existing lesson attachment upload has no test coverage β€” tests/Feature/Course/CourseAuthoringTest.php contains no Storage::fake() or UploadedFile usage. Unrelated to video; still open.


Public Feed 🟒

Out-of-BRD social module. A global, role-agnostic feed where any authenticated user can post, react, comment, share/repost, run polls, tag with hashtags, connect with other users, and report content β€” built on a mutual connection graph, distinct from the membership-gated Community feed (Phase 5). Full endpoint reference: docs/feed-api.md.

Scope decisions (locked): all endpoints under a single feed prefix (/api/v1/feed/..., auth:sanctum) shared by every user; mutual connections via a request/accept handshake (LinkedIn-style β€” replaces the earlier one-directional follow graph); one unified feed β€” own posts + connections' posts (incl. their connections-only) + all public posts in one ranked stream (the former separate discover feed has been folded in and its endpoint removed); a suggested-profiles ("who to connect with") endpoint ranked by expertise + connection-graph match; each post's author carries connection_status / is_me; multi-type reactions (like/love/laugh/wow/sad/angry) polymorphic over posts and comments; multi-choice polls with optional expiry; share/repost flattens to the root post; reporting is capture-only (polymorphic, pending) with no admin moderation surface yet. Notifications are database-channel, synchronous.

Folder structure: models under app/Models/Feed/ (namespaced App\Models\Feed); factories under database/factories/Feed/; enums under app/Enums/Feed; services under app/Services/Feed; exceptions under app/Exceptions/Feed; notifications under app/Notifications/Feed; requests/resources under App\Http\{Requests,Resources}\Feed; controllers under Api/V1/Feed; routes in routes/api/feed.php β†’ routes/api/v1/feed/{timeline,search,posts,connections,profiles,suggestions,hashtags}.php.

Deliverable Status Notes
Data layer β€” connections, feed_posts, feed_polls, feed_poll_options, feed_poll_votes, feed_comments, feed_reactions, feed_reports, hashtags, feed_post_hashtag 🟒 10 migrations; UUIDs, enum casts, factories; polymorphic reactions/reports; denormalized counters; hashtag pivot (composite key)
Enums β€” FeedPostType, FeedPostVisibility, ConnectionStatus, FeedReactionType, FeedReportReason, FeedReportStatus + NotificationType feed cases 🟒 label() + options()
Connection graph β€” mutual request/accept, remove/cancel/decline (self-connect & already-connected 422), connections + incoming/outgoing pending lists with connection_status annotation 🟒 Converted this cycle from one-way follows β€” connections table (requester_id/addressee_id/status); reciprocal request auto-accepts; request + accepted notifications; connections/pending (incoming) & connections/sent (outgoing)
Unified feed β€” own + connections (incl. connections-only) + all public posts in one ranked stream 🟒 Replaces the split timeline/discover; my_reaction annotated, paginated; author carries connection_status + is_me; eager-loaded author/poll/shared-post
Suggested profiles β€” paginated "who to connect with", excludes self & already-linked (pending or connected) 🟒 Ranked by shared_expertiseΒ·3 + mutual_connectionsΒ·2 + LN(connections+1); expertise from mentor & mentee profiles; graceful fallback to graph + popularity. See suggestions detail
Ranking β€” engagement "hot" score (?sort=top default, ?sort=recent for chronological) 🟒 Persisted feed_posts.hot_score + decay; orders the unified feed. See ranking detail
Posts β€” create (text/poll), image attachments (medialibrary), show/update/delete (author-only 403), share/repost 🟒 Share flattens to root; increments share_count
Reactions β€” add/change/remove, one per user per target, polymorphic over posts & comments, counter-synced 🟒 Reacting notifies the post author; post object carries a per-type reactions breakdown ({type, count}, count-desc) on every response alongside the reaction_count total
Comments β€” create + one-level threaded replies, list, delete (author-only), comment_count sync 🟒 Notifies the post author
Polls β€” single/multi-choice, expiry enforcement, vote replace/retract, per-option counts + voted_by_me 🟒 Invalid option / expired / single-choice-violation β†’ 422
Reporting β€” polymorphic report of post or comment, one per user (duplicate 409) 🟒 Capture-only; pending status
Hashtags β€” auto-parsed from post body, list/autocomplete endpoint 🟒 Added this cycle β€” #tags extracted (letter-led, lowercased, deduped, numeric-only ignored) & re-synced on edit; returned as hashtags on the post object; GET /feed/hashtags?search= ranks by usage / filters by name
Feed search β€” single post search by body text or hashtag 🟒 Added this cycle β€” GET /feed/search?q= matches body OR hashtag (leading # ignored); same visibility + sort/per_page as the unified feed
Notifications β€” connection request, connection accepted, post commented, post reacted + NotificationType cases 🟒 Database channel, synchronous
Tests 🟒 83 feature tests (connections 14 · posts 9 · unified-feed 7 · reactions 6 · comments 5 · polls 6 · reports 4 · ranking 8 · profile 10 · suggestions 4 · hashtags/search 10)

Public Feed β€” post ranking ("hot" score)

The feed defaults to an engagement-ranked order (a Hacker-News / Reddit–style "hot" score) rather than pure recency. Clients opt back into chronological with ?sort=recent. Ranking orders the single unified feed (own + connections + all public posts); the membership set (connection graph βˆͺ public) and visibility filters are unchanged β€” only the ordering differs. Under ?sort=top a highly-engaged public post from a stranger can surface near the top; under ?sort=recent popularity is ignored and every included post is ordered purely by recency.

How the score is computed. Each post carries a persisted feed_posts.hot_score (double). It blends weighted engagement over a time-decay denominator:

weightedEngagement = reaction_count + WcΒ·comment_count + WsΒ·share_count
hot_score          = weightedEngagement / POW(hoursSinceCreated + BASE, GRAVITY)
  • Numerator β€” weighted engagement. Reuses the denormalized counters already on the row (no aggregation query). Costlier signals count for more: a comment is worth Wc reactions, a share Ws. Defaults Wc = 2, Ws = 3.
  • Denominator β€” gravity (age decay). As a post ages the divisor grows, so an older post needs disproportionately more engagement to outrank a fresh one. BASE (default 2) stops a zero-age post from dividing by ~0; GRAVITY (default 1.5) controls how fast interest fades β€” higher = steeper decay.
  • Worked example. A 2h-old post with 10 reactions + 3 comments scores (10 + 2Β·3) / (2 + 2)^1.5 = 16 / 8 = 2.0. The same engagement at 48h scores 16 / (48 + 2)^1.5 β‰ˆ 0.045 β€” a ~44Γ— drop from age alone. That is what pushes stale-but-once-popular posts down.

Why (and how) it is kept fresh. Because the denominator depends on "now", a stored score drifts as time passes. Two mechanisms refresh it:

  • Live, per-post β€” every reaction / comment / share recomputes that one post's hot_score inside the same DB transaction (FeedRankingService::recomputeSingle()), so a burst of activity climbs immediately, between sweeps.
  • Scheduled sweep β€” feed:recompute-hot-scores (every 5 min, registered in routes/console.php) rescores every post created within ranking.recompute_window_days (default 7). Older posts have already decayed to ~0 and rarely change rank order, so they are skipped for efficiency.

Ordering & pagination. ?sort=top orders by hot_score DESC, created_at DESC β€” created_at is a stable tiebreaker so page-based pagination doesn't drift when scores tie. ?sort=recent keeps the original created_at DESC. Composite indexes (visibility, hot_score) and (user_id, hot_score) back the public and connection-graph branches of the unified feed query respectively.

Tuning. Every knob lives in config/feed.php β†’ ranking.* and is env-overridable (FEED_RANKING_COMMENT_WEIGHT, FEED_RANKING_SHARE_WEIGHT, FEED_RANKING_GRAVITY, FEED_RANKING_BASE_OFFSET, FEED_RANKING_RECOMPUTE_WINDOW_DAYS) β€” weights, gravity, and the recompute window retune with no code change.

Artifacts.

Piece Location
Scoring engine app/Services/Feed/FeedRankingService.php (scoreExpression Β· recomputeSingle Β· recomputeRecent)
Sort enum app/Enums/Feed/FeedSort.php (recent / top)
Column + indexes + backfill database/migrations/2026_07_14_072841_add_hot_score_to_feed_posts_table.php
Sort-aware queries app/Services/Feed/FeedPostService.php (feed() Β· applySort)
Request validation / default app/Http/Requests/Feed/TimelineRequest.php
Scheduled recompute app/Console/Commands/Feed/RecomputeFeedHotScores.php + routes/console.php
Config config/feed.php

API note. Because top is now the default, existing clients see ranked order unless they pass ?sort=recent.

Breaking changes (this cycle). (1) GET /api/v1/feed/discover removed β€” its content is now part of GET /api/v1/feed. (2) The one-way follow graph was replaced by a mutual connection graph: the POST/DELETE feed/users/{user}/follow + followers/following routes are gone, replaced by the feed/connections/* endpoints (request/accept/remove, connections + pending lists). (3) Post visibility: followers renamed to connections; author/profile is_following β†’ connection_status; feed.new_follower notification β†’ feed.connection_request + feed.connection_accepted.

Public Feed β€” suggested profiles

GET /api/v1/feed/suggestions returns a paginated "who to connect with" list. The caller and everyone they already have an edge with (pending or connected, either direction) are excluded; inactive/banned users are omitted. Candidates are ranked by a blended score:

score = shared_expertise_areasΒ·3 + mutual_connectionsΒ·2 + LN(connections_count + 1)
  • shared_expertise_areas β€” overlap between the caller's and the candidate's expertise areas, drawn from both mentor and mentee profiles (mentor_expertise_areas / mentee_expertise_areas).
  • mutual_connections β€” people connected to the caller who are also connected to the candidate (connection-graph proximity).
  • connections_count β€” overall popularity, log-dampened so it only breaks ties.

When the caller has no expertise signal the score degrades gracefully to the graph + popularity terms (a plain "who to connect with"). Each row exposes the "why" via mutual_connections_count, shared_expertise_count, plus connection_status (always none, since linked users are excluded).

Artifacts.

Piece Location
Suggestion engine app/Services/Feed/FeedSuggestionService.php (suggest Β· correlated match/graph subqueries)
Controller app/Http/Controllers/Api/V1/Feed/SuggestionController.php
Resource app/Http/Resources/Feed/SuggestedProfileResource.php
Route routes/api/v1/feed/suggestions.php (feed.suggestions)

Public Feed β€” deferred / cross-phase

Item Present Unblocks with
Admin moderation surface (list/resolve reports, hide/remove content) feed_reports captured (status column) Future moderation cycle
Blocking / mute between users β€” Future feed cycle
Hashtags + post/hashtag search 🟒 shipped this cycle (GET /feed/hashtags, GET /feed/search) β€”
Mentions / advanced full-text search (relevance-ranked, typo-tolerant) current search is LIKE-based over body + hashtag Future feed cycle / search engine
Queued notification fan-out; email / push channels 🟒 Queued fan-out + Reverb broadcast delivered (Phase 10); branded email templates land with L5; push deferred Mail polish: Phase 10 L5

Phase 10 β€” Notifications & Polish 🟑 (notification module L1–L4 shipped)

Scope decisions (locked): lean stack β€” database queue driver + queue:work (no Horizon: requires pcntl, absent on the Windows dev machine; laravel/horizon stays installed for a later Redis upgrade, enabled by config.platform pins in composer.json). Reverb websockets required and delivered. Deferred by explicit decision: FCM push (needs a mobile app), the per-type notification preference matrix, branded mail (L5), review system, admin dashboard, analytics. Full architecture + setup + catalog: docs/notification-system.md (/docs/notification-system).

Architecture (L1): every notification extends App\Notifications\BaseNotification β€” via() is final and delegates to Services\Notification\NotificationChannelResolver, the single decision point for delivery channels (currently passthrough + broadcast mirroring; the preference matrix plugs in here later). Identity via App\Enums\Notification\NotificationType (39 string-backed cases; moved from the old Enums\Admin\ home β€” string values unchanged, so no data migration). Contracts\BypassesPreferences marks unsuppressible notifications (auth 2FA/verify, admin account.* which are mail-only by necessity). An arch test enforces the base class on the whole App\Notifications namespace. Verified by spike: public readonly Eloquent constructor props survive SerializesModels round-trips on PHP 8.3.

Delivered Status Notes
L1 β€” enums (NotificationType/Channel/Priority/DevicePlatform), BaseNotification, resolver, 14 legacy classes converted, arch tests 🟒 zero behaviour change, zero test edits
L2 β€” queued pipeline: ShouldQueue on all non-auth classes, viaQueues() priority mapping (High/Medium/Low β†’ notifications-high/-default/-low, broadcast β†’ broadcasts), chat fan-out collapsed to one Notification::send() 🟒 Auth notifications stay synchronous by design (queued 2FA = login outage)
L3 β€” Reverb: private channel users.{id}.notifications, POST /api/broadcasting/auth (Sanctum), toBroadcast() frames mirroring the REST list-item shape, Broadcast auto-mirrors Database in the resolver 🟒 ⚠️ channel auth uses hash_equals β€” Laravel's stock (int) stub authorizes everyone under UUID keys; pinned by a 403 test
L4 β€” full BRD Β§12 catalog: 31 classes across Booking/Mentor/Course/Community/Chat/Feed/Auth/Admin, 5 booking listeners registered, notification_dispatches dedupe ledger (claim-first insertOrIgnore), reminder commands (24h/1h session Β· 48h follow-up Β· monthly prune), slot-hold 2-min warning as a delayed job (sub-minute precision a cron can't give) 🟒 admin Β§9 classes carry a mandatory reason; account.* are mail-only
Dev tooling β€” public/notification-tester.html (login β†’ live websocket β†’ list/read), raw Pusher protocol, configurable API base/host/TLS 🟒 out of band; not part of the BRD
Production deploy β€” nixpacks.toml: worker --queue= list fixed (was draining only default β€” notifications would never deliver), worker-scheduler + worker-reverb programs added, nginx websocket proxy location /app 🟒 deployed to Coolify; Reverb handshake + channel-auth verified against production

Artifacts.

Piece Location
Base + resolver app/Notifications/BaseNotification.php Β· app/Services/Notification/NotificationChannelResolver.php
Enums app/Enums/Notification/{NotificationType,NotificationChannel,NotificationPriority,DevicePlatform}.php
Catalog app/Notifications/{Auth,Booking,Chat,Community,Course,Feed,Mentor,Admin}/*Notification.php (31)
Listeners app/Listeners/Booking/Send*Notification.php (5, registered in EventServiceProvider)
Commands app/Console/Commands/Notification/{SendSessionReminders,SendFollowUpReminders,PruneNotificationDispatches}.php
Delayed job app/Jobs/Notification/NotifySlotHoldExpiring.php (dispatched from SlotHoldService)
Dedupe ledger database/migrations/2026_07_22_054412_create_notification_dispatches_table.php
Broadcasting routes/channels.php Β· bootstrap/app.php (withBroadcasting, prefix api + auth:sanctum) Β· config/{broadcasting,reverb}.php
Tests tests/Feature/Notification/ (8 files) Β· tests/Unit/Notification/ (2 files)

Phase 10 β€” remaining / deferred

Item Present Unblocks with
L5 β€” branded mail layout, markdown templates for rich emails, List-Unsubscribe headers toMail() defaults only; MAIL_MAILER=log Phase 10 L5
Per-type notification preference matrix + unsubscribe endpoints resolver seam exists (unused params reserved) before large-scale email sending
FCM push channel + device-token registry NotificationChannel::Push + DevicePlatform enum scaffolded mobile app existence
Redis + Horizon queue upgrade config.platform pins ready; viaQueues() already emits the queue names queue volume demands it
Review system Β· admin dashboard Β· analytics β€” (admin BRD approved in docs/admin-brd.md) remainder of Phase 10