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 thedocs/*-api.mdfiles directly. New docs must be registered inDocsController::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
- Verification-document upload β new
VerificationDocumentController(index/store/download/destroy),VerificationDocumentService(privatelocaldisk),StoreVerificationDocumentRequest,VerificationDocumentResource,VerificationDocumentType+VerificationDocumentStatusenums. 9 tests. - 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 returnsmethod+method_label. - Mentee profile β
showreturns 404 (not 500) when no profile;updatefirstOrCreates 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_holdsrows are not deleted on expiry β intentional. Availability is driven solely byavailability_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
Wcreactions, a shareWs. DefaultsWc = 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(default2) stops a zero-age post from dividing by ~0;GRAVITY(default1.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 scores16 / (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_scoreinside 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 inroutes/console.php) rescores every post created withinranking.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
topis now the default, existing clients see ranked order unless they pass?sort=recent.Breaking changes (this cycle). (1)
GET /api/v1/feed/discoverremoved β its content is now part ofGET /api/v1/feed. (2) The one-way follow graph was replaced by a mutual connection graph: thePOST/DELETE feed/users/{user}/follow+followers/followingroutes are gone, replaced by thefeed/connections/*endpoints (request/accept/remove, connections + pending lists). (3) Postvisibility: followersrenamed toconnections;author/profileis_followingβconnection_status;feed.new_followernotification β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 |