Skip to content

ntalbotdev/answerly-nuxt

Repository files navigation

Answerly Nuxt App

A robust Nuxt 4 CRUD application leveraging Supabase for authentication, database management, and profile asset storage, Pinia for state handling, and Tailwind CSS for modern UI styling. Testing is provided via Vitest for unit tests and Playwright for end-to-end automation.

Features

  • Supabase Auth (email/password)
    • Email verification on signup
    • Password reset functionality
  • User profile creation and management
  • Public profile pages
  • Profile editing and assets upload (avatar/banner via Supabase Storage)
  • Ask questions to any user (optionally anonymously)
    • Anonymous questions protect user privacy and show as "Anonymous" in notifications
  • Users can answer questions they receive
  • Questions are only published after being answered
  • Follow/unfollow users (social feature)
  • View followers and following lists for any user
  • See mutual follow status on profiles
  • Real-time notification system with automatic cleanup
    • Get notified when someone follows you
    • Get notified when someone asks you a question
    • Get notified when someone answers your question
    • Notifications are automatically removed when actions are completed (questions answered/deleted, users unfollowed)
    • Real-time updates using Supabase subscriptions
    • Mark notifications as read (delete from system)
    • Clear all notifications at once
  • Real-time inbox system
    • Questions appear instantly when received
    • Real-time updates when questions are answered or deleted
  • Pinia for state management
  • Middleware for route protection and redirects
  • See the full TODO list for upcoming features and improvements

Project Structure

  • app/ β€” Main application source folder (Nuxt 4 standard)
    • assets/ β€” Static assets
    • components/ β€” Reusable Vue components
    • composables/ β€” Reusable composable functions
    • layouts/ β€” Nuxt layouts
    • middleware/ β€” Route guards and redirects
    • pages/ β€” Nuxt pages (routes)
      • profile/[username]/ β€” Profile pages using shared components
    • stores/ β€” Pinia stores (profile, questions, notifications)
    • utils/ β€” Utility functions and constants
  • scripts/ β€” Scripts for automation and development tasks
  • tests/ β€” Unit and end-to-end tests
    • e2e/ β€” End-to-end tests using Playwright
    • unit/ β€” Unit tests using Vitest

Environment Setup

  1. Environment Variables

    • Create a .env file in the project root with:
      SUPABASE_URL=your-supabase-url
      SUPABASE_ANON_KEY=your-supabase-anon-key
      
  2. Nuxt Modules

    • @nuxtjs/supabase
    • @pinia/nuxt
    • @nuxtjs/tailwindcss
    • @nuxt/test-utils/module
    • @nuxt/eslint
    • @nuxt/icon
  3. Install dependencies

    npm install
    

Supabase Setup

Enable Auth & Database

  • Go to Supabase
  • Create a new project (Supabase Database/Postgres)
  • Enable email/password authentication in the Auth settings

Database Schema

profiles Table

Column Type Description
user_id uuid Primary key, Default: auth.uid()
username text Unique, required
display_name text Nullable, user display name
avatar_url text Nullable, profile picture URL
banner_url text Nullable, profile banner URL
bio text Nullable, user bio
created_at timestamptz Default: now()
updated_at timestamptz Default: now()
πŸ“„ Profiles Table SQL Query
create table profiles (
  user_id uuid primary key default auth.uid() on delete cascade,
  username text unique not null,
  display_name text,
  avatar_url text,
  banner_url text,
  bio text,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

follows Table

Column Type Description
follower_id uuid Primary key, references profiles(user_id)
following_id uuid Primary key, references profiles(user_id)
created_at timestamptz Default: now()
πŸ“„ Follows Table SQL Query
create table follows (
  follower_id uuid references profiles(user_id) on delete cascade,
  following_id uuid references profiles(user_id) on delete cascade,
  created_at timestamptz not null default now(),
  primary key (follower_id, following_id)
);

questions Table

Column Type Description
id uuid Primary key
from_user_id uuid From user, required
to_user_id uuid To user, required
question text Required
is_anonymous boolean Default: false
answer text Nullable
published boolean Default: false
created_at timestamptz Default: now()
answered_at timestamptz Nullable
πŸ“„ Questions Table SQL Query
create table questions (
  id uuid primary key default gen_random_uuid(),
  from_user_id uuid not null references profiles(user_id) on delete cascade,
  to_user_id uuid not null references profiles(user_id) on delete cascade,
  question text not null,
  is_anonymous boolean not null default false,
  answer text,
  published boolean not null default false,
  created_at timestamptz not null default now(),
  answered_at timestamptz
);

notifications Table

Column Type Description
id uuid Primary key
user_id uuid User ID, required
type text Notification type
payload jsonb Nullable, Flexible data
created_at timestamptz Default: now()
event_id text Generated from payload
πŸ“„ Notifications Table SQL Query
create table notifications (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references profiles(user_id) on delete cascade,
  type text not null check (type in ('follow', 'question', 'answer', 'system')),
  payload jsonb,
  created_at timestamptz not null default now(),
  event_id text generated always as (
    case
      when type = 'follow' then COALESCE(payload::jsonb->>'follower_id', '') || ':' || COALESCE(payload::jsonb->>'following_id', '')
      when type = 'question' then COALESCE(payload::jsonb->>'question_id', '')
      when type = 'answer' then COALESCE(payload::jsonb->>'question_id', '')
      else COALESCE(id::text, '')
    end
  ) stored,
  unique (user_id, type, event_id)
);

Edge Functions

πŸ”§ send-notification Edge Function
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

serve(async (req) => {
  const origin = req.headers.get("origin") || "*";
  if (req.method === "OPTIONS") {
    return new Response(null, {
      status: 204,
      headers: {
        "Access-Control-Allow-Origin": origin,
        "Access-Control-Allow-Methods": "POST, DELETE, OPTIONS",
        "Access-Control-Allow-Headers": "Content-Type, Authorization"
      }
    });
  }
  const supabaseUrl = Deno.env.get("SUPABASE_URL");
  const supabaseServiceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
  if (!supabaseUrl || !supabaseServiceRoleKey) {
    return new Response("Missing environment variables", {
      status: 500,
      headers: {
        "Access-Control-Allow-Origin": origin
      }
    });
  }
  const supabase = createClient(supabaseUrl, supabaseServiceRoleKey);
  if (req.method === "DELETE") {
    const { user_id, event_id, type } = await req.json();
    if (!user_id || !event_id || !type) {
      return new Response("Missing required fields for deletion", {
        status: 400,
        headers: {
          "Access-Control-Allow-Origin": origin
        }
      });
    }
    const { error } = await supabase.from("notifications")
      .delete()
      .eq("user_id", user_id)
      .eq("event_id", event_id)
      .eq("type", type);
    if (error) {
      return new Response(`Error deleting notification: ${error.message}`, {
        status: 500,
        headers: {
          "Access-Control-Allow-Origin": origin
        }
      });
    }
    return new Response("Notification deleted", {
      status: 200,
      headers: {
        "Access-Control-Allow-Origin": origin
      }
    });
  }
  const { user_id, type, payload } = await req.json();
  if (!user_id || !type) {
    return new Response("Missing required fields", {
      status: 400,
      headers: {
        "Access-Control-Allow-Origin": origin
      }
    });
  }
  const { data, error } = await supabase.from("notifications").upsert([
    {
      user_id,
      type,
      payload
    }
  ], {
    onConflict: [
      "user_id",
      "type",
      "event_id"
    ]
  }).select();
  if (error) {
    return new Response(`Error: ${error.message}`, {
      status: 500,
      headers: {
        "Access-Control-Allow-Origin": origin
      }
    });
  }
  return new Response("Notification inserted", {
    status: 200,
    headers: {
      "Access-Control-Allow-Origin": origin
    }
  });
});

Storage & Profile Assets

  • Each user can upload an avatar and a banner image to their own folder: [user_id]/avatar.webp and [user_id]/banner.webp
  • The avatar_url and banner_url fields in the profile point to the public URLs of the uploaded images
  • Make the bucket public for public URLs
  • Use the RLS policies below to restrict access to profile assets

Row Level Security (RLS)

ℹ️ Click the spoilers below to reveal the SQL queries for each policy

πŸ“¦ Storage RLS Policies
CREATE POLICY "Public can view avatar/banner"
  ON storage.objects
  FOR SELECT
  TO public
  USING (
    bucket_id = 'profile-assets'
    AND (
      name LIKE '%/avatar.webp'
      OR name LIKE '%/banner.webp'
    )
  );

CREATE POLICY "Users can upload avatar/banner to their folder"
  ON storage.objects
  FOR INSERT
  TO authenticated
  WITH CHECK (
    bucket_id = 'profile-assets'
    AND (
      name = (select auth.uid())::text || '/avatar.webp'
      OR name = (select auth.uid())::text || '/banner.webp'
    )
  );

CREATE POLICY "Users can update avatar/banner in their folder"
  ON storage.objects
  FOR UPDATE
  TO authenticated
  USING (
    bucket_id = 'profile-assets'
    AND (
      name = (select auth.uid())::text || '/avatar.webp'
      OR name = (select auth.uid())::text || '/banner.webp'
    )
  )
  WITH CHECK (
    bucket_id = 'profile-assets'
    AND (
      name = (select auth.uid())::text || '/avatar.webp'
      OR name = (select auth.uid())::text || '/banner.webp'
    )
  );

CREATE POLICY "Users can delete avatar/banner from their folder"
  ON storage.objects
  FOR DELETE
  TO authenticated
  USING (
    bucket_id = 'profile-assets'
    AND (
      name = (select auth.uid())::text || '/avatar.webp'
      OR name = (select auth.uid())::text || '/banner.webp'
    )
  );
πŸ‘€ Profiles RLS Policies
CREATE POLICY "Public can view profiles"
  ON profiles
  FOR SELECT
  TO public
  USING (true);

CREATE POLICY "Users can create their own profile"
  ON profiles
  FOR INSERT
  TO authenticated
  WITH CHECK (
    user_id = (select auth.uid())
  );

CREATE POLICY "Users can update their own profile"
  ON profiles
  FOR UPDATE
  TO authenticated
  USING (
    user_id = (select auth.uid())
  )
  WITH CHECK (
    user_id = (select auth.uid())
  );
🀝 Follows RLS Policies
CREATE POLICY "Everyone can view follows"
  ON follows
  FOR SELECT
  TO public
  USING (true);

CREATE POLICY "Users can follow others"
  ON follows
  FOR INSERT
  TO authenticated
  WITH CHECK (
    follower_id = (select auth.uid())
  );

CREATE POLICY "Users can unfollow others"
  ON follows
  FOR DELETE
  TO authenticated
  USING (
    follower_id = (select auth.uid())
  );
❓ Questions RLS Policies
CREATE POLICY "Public can view published questions"
  ON questions
  FOR SELECT
  TO public
  USING (
    published = true
  );

CREATE POLICY "Users can view their own questions"
  ON questions
  FOR SELECT
  TO authenticated
  USING (
    from_user_id = (select auth.uid())
    OR to_user_id = (select auth.uid())
  );

CREATE POLICY "Users can ask questions"
  ON questions
  FOR INSERT
  TO authenticated
  WITH CHECK (
    from_user_id = (select auth.uid())
  );

CREATE POLICY "Users can answer questions sent to them"
  ON questions
  FOR UPDATE
  TO authenticated
  USING (
    to_user_id = (select auth.uid())
  )
  WITH CHECK (
    to_user_id = (select auth.uid())
  );

CREATE POLICY "Users can delete questions they asked or received"
  ON questions
  FOR DELETE
  TO authenticated
  USING (
    from_user_id = (select auth.uid())
    OR to_user_id = (select auth.uid())
  );
πŸ”” Notifications RLS Policies
CREATE POLICY "Users can view their notifications"
  ON notifications
  FOR SELECT
  TO authenticated
  USING (
    user_id = (select auth.uid())
  );

CREATE POLICY "Users can update their notifications"
  ON notifications
  FOR UPDATE
  TO authenticated
  USING (
    user_id = (select auth.uid())
  )
  WITH CHECK (
    user_id = (select auth.uid())
  );

CREATE POLICY "Users can delete their notifications"
  ON notifications
  FOR DELETE
  TO authenticated
  USING (
    user_id = (select auth.uid())
  );

CREATE POLICY "Allow system inserts for notifications"
  ON notifications
  FOR INSERT
  TO service_role
  WITH CHECK (true);

Usage

  • Sign up and log in with email/password (needs email verification)
  • After signup, a profile is created in the profiles table
  • Visit / as a guest to see the welcome page; as a logged-in user, you'll see your feed
  • Visit /inbox to answer questions sent to you (only published after answering)
    • Real-time updates when new questions arrive
    • Answering or deleting questions automatically removes related notifications
  • Visit /notifications to see real-time notifications for user activity and events
    • Follow notifications: See who followed you
    • Question notifications: See new questions you received
    • Answer notifications: See when your questions are answered
    • Click "Mark as read" to permanently delete individual notifications
    • Use "Clear all" to remove all notifications at once
  • Visit /my-questions to see questions you have asked others
  • Visit /profile/:username to view a public profile (ex: /profile/axile)
    • If it's your own profile, you can edit it by clicking the edit button
    • Follow/unfollow users with automatic notification management
  • Visit /profile/:username/followers to see a user's followers
  • Visit /profile/:username/following to see who a user is following

Development

npm run dev

Testing

  • Run unit tests using Vitest:

    npm run test
  • Run end-to-end tests using Playwright:

    npm run test:e2e
    • This will generate tests/e2e/auth.json with authenticated user session data and open Playwright in UI mode.
  • Use Playwright codegen to generate tests:

    npm run codegen:e2e
    • This will open a browser window where you can interact with the app and record your actions.

Test User

  • Email: test@test.com
  • Password: test123

Built with Nuxt, Supabase, Pinia, and Tailwind CSS.