diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2bc24f5 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# JWT Secret for authentication (CHANGE THIS TO A RANDOM STRING IN PRODUCTION!) +JWT_SECRET=change-this-to-a-secure-random-string-at-least-32-characters-long + +# SQLite database is automatically created in data/skytracker.db +# No additional database configuration needed diff --git a/.gitignore b/.gitignore index f29a6c4..d37907e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,5 +23,11 @@ logs .env.* !.env.example +# Database +data/ +*.db +*.db-shm +*.db-wal + bun.lockb package-lock.json \ No newline at end of file diff --git a/AUTH_SETUP.md b/AUTH_SETUP.md new file mode 100644 index 0000000..a9fdbcf --- /dev/null +++ b/AUTH_SETUP.md @@ -0,0 +1,99 @@ +# Authentication Setup + +This application now uses a custom JWT-based authentication system instead of Clerk. + +## Environment Variables + +Add the following to your `.env` file: + +```env +# JWT Secret for token signing (use a long random string in production) +JWT_SECRET=your-secure-random-secret-key-here + +# Database (SQLite - no configuration needed, auto-created in data/ directory) +``` + +## Database + +The application uses SQLite with better-sqlite3 and Drizzle ORM. The database file is automatically created at `data/skytracker.db` on first run. + +### Schema + +- **users**: User accounts with email/password authentication +- **user_settings**: User preferences and privacy settings +- **notifications**: User notifications +- **partial_skylanders**: Skylander figure data +- **daily_metadata**: Daily random skylander selection +- **user_wishlist**: User wishlist items +- **user_figures**: User collection items +- **user_watching**: Items user is watching +- **auth_sessions**: JWT token sessions (for future token revocation) + +## Authentication Flow + +### Registration +- POST `/api/auth/register` +- Required: email, username, password +- Optional: firstName +- Returns: user object and JWT token + +### Login +- POST `/api/auth/login` +- Required: email, password +- Returns: user object and JWT token + +### Token Verification +- GET `/api/auth/me` +- Headers: `ST-Auth-Token: ` +- Returns: current user object + +## Frontend Usage + +The `useAuth` composable provides authentication state and methods: + +```vue + +``` + +## Protected Routes + +All API routes under `/api/v1` that require authentication use the `ST-Auth-Token` header. The middleware automatically validates the token and attaches the user object to the request context. + +## Security Notes + +1. **JWT_SECRET**: Use a long, random string in production (at least 32 characters) +2. **Password Hashing**: Passwords are hashed using bcrypt with 10 salt rounds +3. **Token Expiry**: JWT tokens expire after 7 days +4. **HTTPS**: Always use HTTPS in production to protect tokens in transit + +## Migration from Clerk + +If you have existing Clerk users, you'll need to: +1. Export user data from Clerk +2. Create a migration script to import users into the new SQLite database +3. Send password reset emails to all users + +Note: Existing MongoDB data needs to be migrated to SQLite before the application will work properly. diff --git a/README.md b/README.md index 739a047..df542db 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,21 @@ A Skylander collector's most useful multitool Track your collection, wishlist your favorites, and watch prices on needs. Skytracker is a website for Skylanders collectors to manage their collection, track prices of desired figures, and compare their collection with other avid collectors. + +## Recent Major Updates + +**Database & Authentication Migration (Latest)** +- ✅ Migrated from MongoDB to SQLite for simplified deployment +- ✅ Replaced Clerk with custom email/password authentication +- ✅ JWT-based token system for secure API access +- 📖 See [AUTH_SETUP.md](AUTH_SETUP.md) for setup instructions + +## Features +- Track your Skylanders collection +- Manage your wishlist +- Watch prices on figures you want +- Compare collections with other collectors +- Browse all Skylanders figures by game, category, or element Screenshots (taken 11/10/24): @@ -28,3 +43,14 @@ Collection/wishlist/watchlist (they have the same general layout): Profile: ![profile](https://github.com/user-attachments/assets/eb0b12e8-70aa-488e-bac4-440dab6e9b7b) + +## Setup + +1. Clone the repository +2. Copy `.env.example` to `.env` and update values (especially JWT_SECRET) +3. Install dependencies: `npm install` +4. Run development server: `npm run dev` +5. Build for production: `npm run build` + +The SQLite database will be automatically created on first run. + diff --git a/components/LoginModal.vue b/components/LoginModal.vue new file mode 100644 index 0000000..7f56a94 --- /dev/null +++ b/components/LoginModal.vue @@ -0,0 +1,83 @@ + + + diff --git a/components/RegisterModal.vue b/components/RegisterModal.vue new file mode 100644 index 0000000..487c79c --- /dev/null +++ b/components/RegisterModal.vue @@ -0,0 +1,107 @@ + + + diff --git a/components/navbar.vue b/components/navbar.vue index fc32042..519af40 100644 --- a/components/navbar.vue +++ b/components/navbar.vue @@ -2,8 +2,10 @@ const router = useRouter(); const colorMode = useColorMode(); const hovering = ref(false); -const { isLoaded, isSignedIn, user } = useUser(); -const clerk = useClerk(); +const { isLoaded, isSignedIn, user, logout } = useAuth(); + +const showLoginModal = ref(false); +const showRegisterModal = ref(false); const items = ["logo", "account"]; @@ -28,6 +30,26 @@ function hover() { hovering.value = !hovering.value; console.log(hovering.value); } + +function openLoginModal() { + showLoginModal.value = true; + showRegisterModal.value = false; +} + +function openRegisterModal() { + showRegisterModal.value = true; + showLoginModal.value = false; +} + +function closeModals() { + showLoginModal.value = false; + showRegisterModal.value = false; +} + +function handleLogout() { + logout(); + hovering.value = false; +}
@@ -161,11 +185,11 @@ function hover() { size="sm" class="w-36" truncate="" - @click="push('/@' + user.username)" - :label="'@' + user.username" + @click="push('/@' + user?.username)" + :label="'@' + user?.username" > @@ -245,5 +269,17 @@ function hover() {
+ + + + diff --git a/components/tiles/shortcuts.vue b/components/tiles/shortcuts.vue index 0082a61..719edb7 100644 --- a/components/tiles/shortcuts.vue +++ b/components/tiles/shortcuts.vue @@ -1,9 +1,19 @@ \ No newline at end of file diff --git a/composables/useAuth.ts b/composables/useAuth.ts new file mode 100644 index 0000000..5d41768 --- /dev/null +++ b/composables/useAuth.ts @@ -0,0 +1,130 @@ +export const useAuth = () => { + const user = useState('auth-user', () => null); + const token = useState('auth-token', () => null); + const isLoaded = useState('auth-loaded', () => false); + const isSignedIn = computed(() => !!user.value && !!token.value); + + // Initialize auth state from localStorage on client + const initAuth = () => { + if (process.client) { + const storedToken = localStorage.getItem('auth-token'); + const storedUser = localStorage.getItem('auth-user'); + + if (storedToken && storedUser) { + token.value = storedToken; + user.value = JSON.parse(storedUser); + } + + isLoaded.value = true; + } + }; + + // Login function + const login = async (email: string, password: string) => { + try { + const response = await $fetch('/api/auth/login', { + method: 'POST', + body: { email, password }, + }); + + if (response.token && response.user) { + token.value = response.token; + user.value = response.user; + + if (process.client) { + localStorage.setItem('auth-token', response.token); + localStorage.setItem('auth-user', JSON.stringify(response.user)); + } + + return { success: true }; + } + + return { success: false, error: 'Invalid response from server' }; + } catch (error: any) { + return { success: false, error: error.data?.message || 'Login failed' }; + } + }; + + // Register function + const register = async (email: string, username: string, password: string, firstName?: string) => { + try { + const response = await $fetch('/api/auth/register', { + method: 'POST', + body: { email, username, password, firstName }, + }); + + if (response.token && response.user) { + token.value = response.token; + user.value = response.user; + + if (process.client) { + localStorage.setItem('auth-token', response.token); + localStorage.setItem('auth-user', JSON.stringify(response.user)); + } + + return { success: true }; + } + + return { success: false, error: 'Invalid response from server' }; + } catch (error: any) { + return { success: false, error: error.data?.message || 'Registration failed' }; + } + }; + + // Logout function + const logout = () => { + token.value = null; + user.value = null; + + if (process.client) { + localStorage.removeItem('auth-token'); + localStorage.removeItem('auth-user'); + } + + // Redirect to home page + navigateTo('/'); + }; + + // Verify token and get current user + const verifyAuth = async () => { + if (!token.value) { + isLoaded.value = true; + return false; + } + + try { + const response = await $fetch('/api/auth/me', { + headers: { + 'ST-Auth-Token': token.value, + }, + }); + + if (response.user) { + user.value = response.user; + if (process.client) { + localStorage.setItem('auth-user', JSON.stringify(response.user)); + } + isLoaded.value = true; + return true; + } + } catch (error) { + // Token is invalid, clear auth state + logout(); + } + + isLoaded.value = true; + return false; + }; + + return { + user, + token, + isLoaded, + isSignedIn, + initAuth, + login, + register, + logout, + verifyAuth, + }; +}; diff --git a/nuxt.config.ts b/nuxt.config.ts index 8e32760..1c57d1a 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -5,7 +5,6 @@ export default defineNuxtConfig({ modules: [ "@nuxt/ui", "@nuxt/icon", - "vue-clerk/nuxt", "@nuxt/image", '@formkit/auto-animate/nuxt', "@nuxtjs/mdc", diff --git a/package.json b/package.json index 23e9bc2..16e5e1a 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "postinstall": "nuxt prepare" }, "dependencies": { - "@clerk/backend": "^1.13.5", "@formkit/auto-animate": "^0.8.2", "@iconify-json/material-symbols": "^1.2.1", "@nuxt/icon": "^1.5.2", @@ -18,21 +17,26 @@ "@nuxt/ui": "^2.18.6", "@nuxtjs/mdc": "^0.9.2", "@types/node-fetch": "^2.6.11", + "bcryptjs": "^3.0.2", + "better-sqlite3": "^12.4.1", "chart.js": "^4.4.4", "cheerio": "^1.0.0", "dotenv": "^16.4.5", + "drizzle-orm": "^0.44.7", "fs": "^0.0.1-security", - "mongoose": "^8.7.0", + "jsonwebtoken": "^9.0.2", "node-fetch": "^2.6.7", "nuxt": "^3.13.0", "nuxt-typed-router": "^3.7.0", "nuxt-umami": "^3.0.2", "vue": "latest", "vue-chartjs": "^5.3.1", - "vue-clerk": "^0.8.1", "vue-router": "latest" }, "devDependencies": { - "@iconify-json/ic": "^1.2.1" + "@iconify-json/ic": "^1.2.1", + "@types/bcryptjs": "^2.4.6", + "@types/jsonwebtoken": "^9.0.10", + "drizzle-kit": "^0.31.6" } } diff --git a/pages/my/settings/index.vue b/pages/my/settings/index.vue index aaf9ce4..8d200ab 100644 --- a/pages/my/settings/index.vue +++ b/pages/my/settings/index.vue @@ -3,12 +3,10 @@ useHead({ title: "skytracker - settings" }); const settingsMetadata = ref(); const tabData = ref([]); -const { user } = useUser(); +const { user, token, logout } = useAuth(); const page = ref("website"); -const clerk = useClerk(); const toast = useToast(); const userSettings = ref(); -const { getToken } = useAuth(); const route = useRoute(); const bio = ref(""); const privacySettings = ref({}); @@ -25,7 +23,7 @@ const goto = (path) => router.push(path); async function fetchMetadata() { const response = await fetch("/api/v1/settings/fetch"); settingsMetadata.value = await response.json(); - const me = await makeAuthenticatedRequest(`/api/v1/me/metadata`, await getToken.value()); + const me = await makeAuthenticatedRequest(`/api/v1/me/metadata`, token.value); bio.value = me.bio; const arr = []; console.log(user.value); @@ -58,14 +56,14 @@ async function fetchMetadata() { label: "Log out", icon: "material-symbols:logout", click: () => { - clerk.signOut(); + logout(); }, }, ]); const userSettingRes = await makeAuthenticatedRequest( `/api/v1/settings/me/fetch`, - await getToken.value() + token.value ); userSettings.value = userSettingRes; @@ -100,7 +98,7 @@ onMounted(() => { async function updateSetting(key, value) { const res = await makeAuthenticatedPostRequest( `/api/v1/settings/me/update`, - await getToken.value(), + token.value, { setting: key, value: value, @@ -116,7 +114,7 @@ async function updateSetting(key, value) { async function updateBio() { const res = await makeAuthenticatedPostRequest( `/api/v1/me/bio`, - await getToken.value(), + token.value, { bio: bio.value, } @@ -138,7 +136,11 @@ async function updateBio() {
- +
+

Profile Settings

+

Manage your profile information

+ +

@@ -251,14 +253,3 @@ async function updateBio() {

- - diff --git a/plugins/auth.client.ts b/plugins/auth.client.ts new file mode 100644 index 0000000..ec79508 --- /dev/null +++ b/plugins/auth.client.ts @@ -0,0 +1,9 @@ +export default defineNuxtPlugin(async () => { + const { initAuth, verifyAuth } = useAuth(); + + // Initialize auth from localStorage + initAuth(); + + // Verify token if present + await verifyAuth(); +}); diff --git a/schemas/dailyMetadata.ts b/schemas/dailyMetadata.ts deleted file mode 100644 index 37fc7bb..0000000 --- a/schemas/dailyMetadata.ts +++ /dev/null @@ -1,15 +0,0 @@ -import mongoose from 'mongoose'; - -const dailyMetadataSchema = new mongoose.Schema({ - date: { - type: Date, - required: true, - unique: true, - default: new Date() - }, - skylander: String -}); - -const DailyMetadata = mongoose.model('DailyMetadata', dailyMetadataSchema); - -export default DailyMetadata; \ No newline at end of file diff --git a/schemas/notification.ts b/schemas/notification.ts deleted file mode 100644 index 468c085..0000000 --- a/schemas/notification.ts +++ /dev/null @@ -1,11 +0,0 @@ -import mongoose from "mongoose"; - -const notificationSchema = new mongoose.Schema({ - message: String, - date: Date, - read: Boolean, -}, { versionKey: false }); - -const Notification = mongoose.model("Notification", notificationSchema); - -export default Notification; \ No newline at end of file diff --git a/schemas/partialSkylander.ts b/schemas/partialSkylander.ts deleted file mode 100644 index 164f549..0000000 --- a/schemas/partialSkylander.ts +++ /dev/null @@ -1,22 +0,0 @@ -import mongoose from "mongoose"; - -const skylanderSchema = new mongoose.Schema({ - name: String, - link: String, - image: String, - category: String, - game: String, - element: String, - releasedWith: String, - series: String, - price: String, - links: { - ebay: String, - amazon: String, - scl: String, - } -}, { versionKey: false }); - -const PartialSkylander = mongoose.model("PartialSkylander", skylanderSchema); - -export default PartialSkylander; \ No newline at end of file diff --git a/schemas/user.ts b/schemas/user.ts deleted file mode 100644 index dbcf3f9..0000000 --- a/schemas/user.ts +++ /dev/null @@ -1,55 +0,0 @@ -import mongoose from "mongoose"; - -const Notification = new mongoose.Schema({ - title: String, - message: String, - date: { - type: Date, - default: Date.now - } -}, { versionKey: false, id: true }); - -const Settings = new mongoose.Schema({ - collectionVisibility: { - type: Boolean, - default: true - }, - wishlistVisibility: { - type: Boolean, - default: true - }, - watchingVisibility: { - type: Boolean, - default: true - }, - language: { - type: String, - default: "english" - } -}, { versionKey: false, id: false }); - -const userSchema = new mongoose.Schema({ - id: { - type: String, - required: true, - unique: true - }, - wishlist: [String], - figures: [String], - watching: [String], - notifications: [Notification], - settings: { - type: Settings, - default: { - collectionVisibility: true, - wishlistVisibility: true, - watchingVisibility: true, - language: "english", - trackers: true - } - } -}, { versionKey: false, id: false }); - -const User = mongoose.model("User", userSchema); - -export default User; \ No newline at end of file diff --git a/scripts/migrate-mongo-to-sqlite.js b/scripts/migrate-mongo-to-sqlite.js new file mode 100644 index 0000000..a7fa132 --- /dev/null +++ b/scripts/migrate-mongo-to-sqlite.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +/** + * MongoDB to SQLite Migration Guide + * + * This file provides guidance on migrating data from MongoDB to SQLite. + * For actual migration, you'll need to customize based on your specific data. + * + * IMPORTANT: This is a guide, not an automated migration script. + */ + +console.log('MongoDB to SQLite Migration Guide'); +console.log('==================================\n'); + +console.log('⚠️ IMPORTANT NOTES:'); +console.log('1. This is a migration GUIDE, not an automated script'); +console.log('2. Users cannot be migrated (Clerk handled auth before)'); +console.log('3. Skylanders data can be repopulated using the scrape script'); +console.log('4. All users will need to re-register with email/password\n'); + +console.log('📋 Recommended Migration Steps:\n'); + +console.log('1. SKYLANDERS DATA:'); +console.log(' Option A: Run the scrape script to repopulate:'); +console.log(' $ npm run dev'); +console.log(' $ node scripts/scrape.ts'); +console.log(' Option B: Export from MongoDB and manually import\n'); + +console.log('2. USER DATA:'); +console.log(' - User accounts CANNOT be migrated (Clerk handled passwords)'); +console.log(' - All users must re-register with email/password'); +console.log(' - Consider sending notification to existing users\n'); + +console.log('3. DAILY METADATA:'); +console.log(' - Will be automatically generated on first access'); +console.log(' - No migration needed\n'); + +console.log('4. SETTINGS & PRIVACY:'); +console.log(' - Default settings applied for new users'); +console.log(' - Users can reconfigure after registration\n'); + +console.log('For custom migration needs, you can:'); +console.log('- Export MongoDB collections as JSON'); +console.log('- Write custom import scripts using server/utils/database.ts functions'); +console.log('- Contact the development team for assistance\n'); + diff --git a/scripts/scrape.ts b/scripts/scrape.ts index 4a0ef61..e99791e 100644 --- a/scripts/scrape.ts +++ b/scripts/scrape.ts @@ -4,7 +4,7 @@ import fetch from "node-fetch"; import * as cheerio from "cheerio"; import { mkdirSync, readdirSync, writeFileSync } from "fs"; -import { savePartialSkylander, wipePartialSkylanders } from "../utils/database"; +import { savePartialSkylander, wipePartialSkylanders } from "../server/utils/database"; const links = [ "https://skylanderscharacterlist.com/spyros-adventure-figures/", diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts new file mode 100644 index 0000000..d298364 --- /dev/null +++ b/server/api/auth/login.post.ts @@ -0,0 +1,70 @@ +import { verifyPassword, generateToken } from '~/server/utils/auth'; +import { getUserByEmail, updateUserLastActive } from '~/server/utils/database'; +import { db } from '~/server/db'; +import { users } from '~/server/db/schema'; +import { eq } from 'drizzle-orm'; + +export default defineEventHandler(async (event) => { + const body = await readBody(event); + const { email, password } = body; + + // Validate input + if (!email || !password) { + throw createError({ + status: 400, + message: 'Email and password are required', + }); + } + + // Get user by email + const user = await getUserByEmail(email); + if (!user) { + throw createError({ + status: 401, + message: 'Invalid email or password', + }); + } + + // Get password hash from database + const userWithPassword = await db.select() + .from(users) + .where(eq(users.id, user.id)) + .limit(1); + + if (userWithPassword.length === 0) { + throw createError({ + status: 401, + message: 'Invalid email or password', + }); + } + + // Verify password + const isValidPassword = await verifyPassword(password, userWithPassword[0].passwordHash); + if (!isValidPassword) { + throw createError({ + status: 401, + message: 'Invalid email or password', + }); + } + + // Update last active + await updateUserLastActive(user.id); + + // Generate token + const token = generateToken({ + userId: user.id, + email: user.email, + username: user.username, + }); + + return { + user: { + id: user.id, + email: user.email, + username: user.username, + firstName: user.firstName, + imageUrl: user.imageUrl, + }, + token, + }; +}); diff --git a/server/api/auth/me.get.ts b/server/api/auth/me.get.ts new file mode 100644 index 0000000..6e1a895 --- /dev/null +++ b/server/api/auth/me.get.ts @@ -0,0 +1,44 @@ +import { verifyToken } from '~/server/utils/auth'; +import { fetchUser } from '~/server/utils/database'; + +export default defineEventHandler(async (event) => { + const authHeader = getHeader(event, 'ST-Auth-Token'); + + if (!authHeader) { + throw createError({ + status: 401, + message: 'Missing authentication token', + }); + } + + const payload = verifyToken(authHeader); + if (!payload) { + throw createError({ + status: 401, + message: 'Invalid or expired token', + }); + } + + const user = await fetchUser(payload.userId); + if (!user) { + throw createError({ + status: 404, + message: 'User not found', + }); + } + + return { + user: { + id: user.id, + email: user.email, + username: user.username, + firstName: user.firstName, + lastName: user.lastName, + imageUrl: user.imageUrl, + bio: user.bio, + banned: user.banned, + createdAt: user.createdAt, + lastActiveAt: user.lastActiveAt, + }, + }; +}); diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts new file mode 100644 index 0000000..786e635 --- /dev/null +++ b/server/api/auth/register.post.ts @@ -0,0 +1,55 @@ +import { hashPassword, generateToken } from '~/server/utils/auth'; +import { createUser, getUserByEmail, getUserByUsername } from '~/server/utils/database'; + +export default defineEventHandler(async (event) => { + const body = await readBody(event); + const { email, username, password, firstName } = body; + + // Validate input + if (!email || !username || !password) { + throw createError({ + status: 400, + message: 'Email, username, and password are required', + }); + } + + // Check if email already exists + const existingEmail = await getUserByEmail(email); + if (existingEmail) { + throw createError({ + status: 409, + message: 'Email already in use', + }); + } + + // Check if username already exists + const existingUsername = await getUserByUsername(username); + if (existingUsername) { + throw createError({ + status: 409, + message: 'Username already in use', + }); + } + + // Hash password and create user + const passwordHash = await hashPassword(password); + const user = await createUser(email, username, passwordHash, firstName); + + // Generate token + const token = generateToken({ + userId: user.id, + email: user.email, + username: user.username, + }); + + return { + user: { + id: user.id, + email: user.email, + username: user.username, + firstName: user.firstName, + imageUrl: user.imageUrl, + }, + token, + }; +}); diff --git a/server/api/v1/collections/fetch.ts b/server/api/v1/collections/fetch.ts index 1c1d332..c3e7b65 100644 --- a/server/api/v1/collections/fetch.ts +++ b/server/api/v1/collections/fetch.ts @@ -1,4 +1,4 @@ -import { fetchCollection } from "~/utils/database"; +import { fetchCollection } from "~/server/utils/database"; export default eventHandler(async (event) => { const userid = event.context.user.id; diff --git a/server/api/v1/collections/modify/[id].ts b/server/api/v1/collections/modify/[id].ts index 18d2901..cf5f564 100644 --- a/server/api/v1/collections/modify/[id].ts +++ b/server/api/v1/collections/modify/[id].ts @@ -1,4 +1,4 @@ -import { toggleCollectionSkylander } from "~/utils/database"; +import { toggleCollectionSkylander } from "~/server/utils/database"; export default eventHandler(async (event) => { const userid = event.context.user.id; diff --git a/server/api/v1/collections/user/[id].ts b/server/api/v1/collections/user/[id].ts index 9ed0ff1..a51d10d 100644 --- a/server/api/v1/collections/user/[id].ts +++ b/server/api/v1/collections/user/[id].ts @@ -1,4 +1,4 @@ -import { fetchCollection } from "~/utils/database"; +import { fetchCollection } from "~/server/utils/database"; export default eventHandler(async (event) => { const userid = getRouterParam(event, "id"); diff --git a/server/api/v1/me/bio.ts b/server/api/v1/me/bio.ts index dc298b9..aff2dfc 100644 --- a/server/api/v1/me/bio.ts +++ b/server/api/v1/me/bio.ts @@ -1,8 +1,10 @@ -import { clerkClient } from "~/utils/database"; +import { updateUserBio } from "~/server/utils/database"; export default eventHandler(async (event) => { const body = await readBody(event); const bio = await JSON.parse(body).bio; - const result = await clerkClient.users.updateUserMetadata(event.context.user.id, { publicMetadata: { bio: bio } }); + + await updateUserBio(event.context.user.id, bio); + return { bio: bio }; }); \ No newline at end of file diff --git a/server/api/v1/me/metadata.ts b/server/api/v1/me/metadata.ts index 5938dc8..658a383 100644 --- a/server/api/v1/me/metadata.ts +++ b/server/api/v1/me/metadata.ts @@ -1,6 +1,8 @@ -import { clerkClient } from "~/utils/database" - export default defineEventHandler(async (event) => { - const user = await clerkClient.users.getUser(event.context.user.id) - return user.publicMetadata + const user = event.context.user; + + // Return user metadata (bio and other public metadata) + return { + bio: user.bio || '', + }; }); \ No newline at end of file diff --git a/server/api/v1/messages/fetch.ts b/server/api/v1/messages/fetch.ts index c0dfeb7..63f1e57 100644 --- a/server/api/v1/messages/fetch.ts +++ b/server/api/v1/messages/fetch.ts @@ -1,4 +1,4 @@ -import { fetchMessages } from "~/utils/database"; +import { fetchMessages } from "~/server/utils/database"; export default eventHandler(async (event) => { const userid = event.context.user.id; diff --git a/server/api/v1/metadata/today.ts b/server/api/v1/metadata/today.ts index 2319757..5ca2b98 100644 --- a/server/api/v1/metadata/today.ts +++ b/server/api/v1/metadata/today.ts @@ -1,4 +1,4 @@ -import { getDailyMetadata } from "~/utils/database"; +import { getDailyMetadata } from "~/server/utils/database"; export default defineCachedEventHandler(async (event) => { const metadata = await getDailyMetadata(new Date()); diff --git a/server/api/v1/partials/[id].ts b/server/api/v1/partials/[id].ts index a03071f..2b7fcaa 100644 --- a/server/api/v1/partials/[id].ts +++ b/server/api/v1/partials/[id].ts @@ -1,4 +1,4 @@ -import { fetchPartialSkylander } from "~/utils/database"; +import { fetchPartialSkylander } from "~/server/utils/database"; export default defineEventHandler(async (event) => { const name = getRouterParam(event, "id"); diff --git a/server/api/v1/partials/all.ts b/server/api/v1/partials/all.ts index ffcf764..c86497f 100644 --- a/server/api/v1/partials/all.ts +++ b/server/api/v1/partials/all.ts @@ -1,4 +1,4 @@ -import { fetchPartialSkylanders } from "~/utils/database" +import { fetchPartialSkylanders } from "~/server/utils/database" export default defineEventHandler(async (event) => { const skylanders = await fetchPartialSkylanders(); diff --git a/server/api/v1/partials/category/[category].ts b/server/api/v1/partials/category/[category].ts index 876f1f0..6409860 100644 --- a/server/api/v1/partials/category/[category].ts +++ b/server/api/v1/partials/category/[category].ts @@ -1,4 +1,4 @@ -import { fetchPartialSkylandersByCategory } from "~/utils/database"; +import { fetchPartialSkylandersByCategory } from "~/server/utils/database"; export default defineEventHandler(async (event) => { let category = getRouterParam(event, "category"); diff --git a/server/api/v1/partials/game/[game].ts b/server/api/v1/partials/game/[game].ts index 5db4826..98aff3d 100644 --- a/server/api/v1/partials/game/[game].ts +++ b/server/api/v1/partials/game/[game].ts @@ -1,4 +1,4 @@ -import { fetchPartialSkylandersByGame } from "~/utils/database"; +import { fetchPartialSkylandersByGame } from "~/server/utils/database"; export default defineEventHandler(async (event) => { const game = getRouterParam(event, "game"); diff --git a/server/api/v1/partials/page/[page].ts b/server/api/v1/partials/page/[page].ts index 880a4db..ce171b4 100644 --- a/server/api/v1/partials/page/[page].ts +++ b/server/api/v1/partials/page/[page].ts @@ -1,4 +1,4 @@ -import { getPageOfPartials } from "~/utils/database"; +import { getPageOfPartials } from "~/server/utils/database"; export default defineEventHandler(async (event) => { const page = getRouterParam(event, 'page'); diff --git a/server/api/v1/searching/[term].ts b/server/api/v1/searching/[term].ts index 372edcf..6b7a53b 100644 --- a/server/api/v1/searching/[term].ts +++ b/server/api/v1/searching/[term].ts @@ -1,4 +1,4 @@ -import { search } from "~/utils/database"; +import { search } from "~/server/utils/database"; import { filterString } from "~/utils/string"; export default defineEventHandler(async (event) => { diff --git a/server/api/v1/settings/me/fetch.ts b/server/api/v1/settings/me/fetch.ts index a28712f..4f86e53 100644 --- a/server/api/v1/settings/me/fetch.ts +++ b/server/api/v1/settings/me/fetch.ts @@ -1,4 +1,4 @@ -import { fetchSettings } from "~/utils/database"; +import { fetchSettings } from "~/server/utils/database"; export default defineEventHandler(async (event) => { const id = event.context.user.id; diff --git a/server/api/v1/settings/me/update.ts b/server/api/v1/settings/me/update.ts index 99dd3bf..4f67950 100644 --- a/server/api/v1/settings/me/update.ts +++ b/server/api/v1/settings/me/update.ts @@ -1,4 +1,4 @@ -import { modifySetting } from "~/utils/database" +import { modifySetting } from "~/server/utils/database" import { turnStringNice } from "~/utils/string" export default eventHandler(async (event) => { diff --git a/server/api/v1/skylanders/[id].ts b/server/api/v1/skylanders/[id].ts index 34daead..540ceb5 100644 --- a/server/api/v1/skylanders/[id].ts +++ b/server/api/v1/skylanders/[id].ts @@ -1,4 +1,4 @@ -import { fetchPartialSkylander } from "~/utils/database"; +import { fetchPartialSkylander } from "~/server/utils/database"; import { getSkylanderData } from "~/utils/scraper"; export default defineCachedEventHandler(async (event) => { diff --git a/server/api/v1/users/[username]/fetch.ts b/server/api/v1/users/[username]/fetch.ts index 752bed5..9bf6ba0 100644 --- a/server/api/v1/users/[username]/fetch.ts +++ b/server/api/v1/users/[username]/fetch.ts @@ -1,4 +1,4 @@ -import { clerkClient } from "~/utils/database"; +import { getUserByUsername } from "~/server/utils/database"; export default eventHandler(async (event) => { const username = getRouterParam(event, "username"); @@ -10,20 +10,25 @@ export default eventHandler(async (event) => { }); } + const user = await getUserByUsername(username); - const result = await clerkClient.users.getUserList({ - username: [username], - }) - - if (result.data.length === 0) { + if (!user) { throw createError({ status: 404, message: "User not found", }); } - const user = result.data[0]; - - return { username: user.username, id: user.id, banned: user.banned, firstName: user.firstName, publicMetadata: user.publicMetadata, lastOnline: user.lastActiveAt, createdAt: user.createdAt, image: user.imageUrl, hasImage: user.hasImage }; + return { + username: user.username, + id: user.id, + banned: user.banned, + firstName: user.firstName, + publicMetadata: { bio: user.bio || '' }, + lastOnline: user.lastActiveAt, + createdAt: user.createdAt, + image: user.imageUrl, + hasImage: !!user.imageUrl + }; }); \ No newline at end of file diff --git a/server/api/v1/watching/fetch.ts b/server/api/v1/watching/fetch.ts index 7820d45..50f95ab 100644 --- a/server/api/v1/watching/fetch.ts +++ b/server/api/v1/watching/fetch.ts @@ -1,4 +1,4 @@ -import { fetchWatchingSkylanders } from "~/utils/database"; +import { fetchWatchingSkylanders } from "~/server/utils/database"; export default eventHandler(async (event) => { const userid = event.context.user.id; diff --git a/server/api/v1/watching/modify/[id].ts b/server/api/v1/watching/modify/[id].ts index 1358c01..f4eb285 100644 --- a/server/api/v1/watching/modify/[id].ts +++ b/server/api/v1/watching/modify/[id].ts @@ -1,4 +1,4 @@ -import { toggleWatchingSkylander } from "~/utils/database"; +import { toggleWatchingSkylander } from "~/server/utils/database"; export default eventHandler(async (event) => { const userid = event.context.user.id; diff --git a/server/api/v1/watching/user/[id].ts b/server/api/v1/watching/user/[id].ts index 627f749..dda5e76 100644 --- a/server/api/v1/watching/user/[id].ts +++ b/server/api/v1/watching/user/[id].ts @@ -1,4 +1,4 @@ -import { fetchWatchingSkylanders } from "~/utils/database"; +import { fetchWatchingSkylanders } from "~/server/utils/database"; export default eventHandler(async (event) => { const userid = getRouterParam(event, "id"); diff --git a/server/api/v1/wishlist/fetch.ts b/server/api/v1/wishlist/fetch.ts index 477ec51..0f35edd 100644 --- a/server/api/v1/wishlist/fetch.ts +++ b/server/api/v1/wishlist/fetch.ts @@ -1,4 +1,4 @@ -import { fetchWishlist } from "~/utils/database"; +import { fetchWishlist } from "~/server/utils/database"; export default eventHandler(async (event) => { const userid = event.context.user.id; diff --git a/server/api/v1/wishlist/modify/[id].ts b/server/api/v1/wishlist/modify/[id].ts index b14cdd6..f85888f 100644 --- a/server/api/v1/wishlist/modify/[id].ts +++ b/server/api/v1/wishlist/modify/[id].ts @@ -1,4 +1,4 @@ -import { toggleWatchingSkylander, toggleWishlist } from "~/utils/database"; +import { toggleWishlist } from "~/server/utils/database"; export default eventHandler(async (event) => { const userid = event.context.user.id; diff --git a/server/api/v1/wishlist/user/[id].ts b/server/api/v1/wishlist/user/[id].ts index c8ec47b..f50d8b8 100644 --- a/server/api/v1/wishlist/user/[id].ts +++ b/server/api/v1/wishlist/user/[id].ts @@ -1,4 +1,4 @@ -import { fetchWishlist } from "~/utils/database"; +import { fetchWishlist } from "~/server/utils/database"; export default eventHandler(async (event) => { const userid = getRouterParam(event, "id"); diff --git a/server/db/index.ts b/server/db/index.ts new file mode 100644 index 0000000..2b4edbd --- /dev/null +++ b/server/db/index.ts @@ -0,0 +1,121 @@ +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import * as schema from './schema'; +import path from 'path'; +import fs from 'fs'; + +// Ensure the data directory exists +const dataDir = path.join(process.cwd(), 'data'); +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +const dbPath = path.join(dataDir, 'skytracker.db'); + +// Create SQLite database connection +const sqlite = new Database(dbPath); +sqlite.pragma('journal_mode = WAL'); + +// Create Drizzle instance +export const db = drizzle(sqlite, { schema }); + +// Initialize database tables +export function initializeDatabase() { + // Create tables if they don't exist + sqlite.exec(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + first_name TEXT, + last_name TEXT, + image_url TEXT, + bio TEXT, + banned INTEGER DEFAULT 0, + created_at INTEGER NOT NULL, + last_active_at INTEGER + ); + + CREATE TABLE IF NOT EXISTS user_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + collection_visibility INTEGER DEFAULT 1, + wishlist_visibility INTEGER DEFAULT 1, + watching_visibility INTEGER DEFAULT 1, + language TEXT DEFAULT 'english' + ); + + CREATE TABLE IF NOT EXISTS notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title TEXT, + message TEXT, + date INTEGER, + read INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS partial_skylanders ( + id TEXT PRIMARY KEY, + name TEXT, + link TEXT, + image TEXT, + category TEXT, + game TEXT, + element TEXT, + released_with TEXT, + series TEXT, + price TEXT, + ebay_link TEXT, + amazon_link TEXT, + scl_link TEXT + ); + + CREATE TABLE IF NOT EXISTS daily_metadata ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date INTEGER NOT NULL UNIQUE, + skylander_id TEXT REFERENCES partial_skylanders(id) + ); + + CREATE TABLE IF NOT EXISTS user_wishlist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + skylander_id TEXT NOT NULL REFERENCES partial_skylanders(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS user_figures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + skylander_id TEXT NOT NULL REFERENCES partial_skylanders(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS user_watching ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + skylander_id TEXT NOT NULL REFERENCES partial_skylanders(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS auth_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at INTEGER NOT NULL, + created_at INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); + CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id); + CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); + CREATE INDEX IF NOT EXISTS idx_user_wishlist_user_id ON user_wishlist(user_id); + CREATE INDEX IF NOT EXISTS idx_user_figures_user_id ON user_figures(user_id); + CREATE INDEX IF NOT EXISTS idx_user_watching_user_id ON user_watching(user_id); + CREATE INDEX IF NOT EXISTS idx_partial_skylanders_name ON partial_skylanders(name); + CREATE INDEX IF NOT EXISTS idx_auth_sessions_token ON auth_sessions(token); + `); +} + +// Initialize on import +initializeDatabase(); + +export default db; diff --git a/server/db/schema.ts b/server/db/schema.ts new file mode 100644 index 0000000..69cce58 --- /dev/null +++ b/server/db/schema.ts @@ -0,0 +1,90 @@ +import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'; + +// Users table with authentication +export const users = sqliteTable('users', { + id: text('id').primaryKey(), + email: text('email').notNull().unique(), + username: text('username').notNull().unique(), + passwordHash: text('password_hash').notNull(), + firstName: text('first_name'), + lastName: text('last_name'), + imageUrl: text('image_url'), + bio: text('bio'), + banned: integer('banned', { mode: 'boolean' }).default(false), + createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), + lastActiveAt: integer('last_active_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}); + +// User settings (separate table for cleaner structure) +export const userSettings = sqliteTable('user_settings', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + collectionVisibility: integer('collection_visibility', { mode: 'boolean' }).default(true), + wishlistVisibility: integer('wishlist_visibility', { mode: 'boolean' }).default(true), + watchingVisibility: integer('watching_visibility', { mode: 'boolean' }).default(true), + language: text('language').default('english'), +}); + +// User notifications +export const notifications = sqliteTable('notifications', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + title: text('title'), + message: text('message'), + date: integer('date', { mode: 'timestamp' }).$defaultFn(() => new Date()), + read: integer('read', { mode: 'boolean' }).default(false), +}); + +// Skylanders (partial data) +export const partialSkylanders = sqliteTable('partial_skylanders', { + id: text('id').primaryKey(), + name: text('name'), + link: text('link'), + image: text('image'), + category: text('category'), + game: text('game'), + element: text('element'), + releasedWith: text('released_with'), + series: text('series'), + price: text('price'), + ebayLink: text('ebay_link'), + amazonLink: text('amazon_link'), + sclLink: text('scl_link'), +}); + +// Daily metadata +export const dailyMetadata = sqliteTable('daily_metadata', { + id: integer('id').primaryKey({ autoIncrement: true }), + date: integer('date', { mode: 'timestamp' }).notNull().unique(), + skylanderId: text('skylander_id').references(() => partialSkylanders.id), +}); + +// User wishlist (many-to-many) +export const userWishlist = sqliteTable('user_wishlist', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + skylanderId: text('skylander_id').notNull().references(() => partialSkylanders.id, { onDelete: 'cascade' }), +}); + +// User collection/figures (many-to-many) +export const userFigures = sqliteTable('user_figures', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + skylanderId: text('skylander_id').notNull().references(() => partialSkylanders.id, { onDelete: 'cascade' }), +}); + +// User watching (many-to-many) +export const userWatching = sqliteTable('user_watching', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + skylanderId: text('skylander_id').notNull().references(() => partialSkylanders.id, { onDelete: 'cascade' }), +}); + +// Auth sessions for JWT tokens (optional, for token revocation) +export const authSessions = sqliteTable('auth_sessions', { + id: integer('id').primaryKey({ autoIncrement: true }), + userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }), + token: text('token').notNull().unique(), + expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), +}); diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 4cca4b6..9b316e7 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,5 +1,5 @@ -import { verifyToken } from "@clerk/backend"; -import { clerkClient } from "~/utils/database"; +import { verifyToken } from "~/server/utils/auth"; +import { fetchUser } from "~/server/utils/database"; const authenticatedRoutes = [ "/api/v1/users/me", @@ -42,25 +42,17 @@ export default defineEventHandler(async (event) => { }) } - const tokenInfo = await verifyToken(authHeader, { - secretKey: process.env.NUXT_CLERK_SECRET_KEY, - }).catch((err) => { + const tokenPayload = verifyToken(authHeader) + if (!tokenPayload) { throw createError({ status: 401, - message: err - }) - }) - - event.context.token = tokenInfo - - if (!tokenInfo?.sub) { - throw createError({ - status: 401, - message: "Invalid token" + message: "Invalid or expired token" }) } - const user = await clerkClient.users.getUser(tokenInfo.sub) + event.context.token = tokenPayload + + const user = await fetchUser(tokenPayload.userId) if (!user) { throw createError({ @@ -69,6 +61,5 @@ export default defineEventHandler(async (event) => { }) } - event.context.user = user }); \ No newline at end of file diff --git a/server/utils/auth.ts b/server/utils/auth.ts new file mode 100644 index 0000000..87e722c --- /dev/null +++ b/server/utils/auth.ts @@ -0,0 +1,53 @@ +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import { randomBytes } from 'crypto'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this-in-production'; +const TOKEN_EXPIRY = '7d'; // 7 days + +export interface JWTPayload { + userId: string; + email: string; + username: string; +} + +/** + * Hash a password using bcrypt + */ +export async function hashPassword(password: string): Promise { + const salt = await bcrypt.genSalt(10); + return bcrypt.hash(password, salt); +} + +/** + * Verify a password against a hash + */ +export async function verifyPassword(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); +} + +/** + * Generate a JWT token for a user + */ +export function generateToken(payload: JWTPayload): string { + return jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY }); +} + +/** + * Verify and decode a JWT token + */ +export function verifyToken(token: string): JWTPayload | null { + try { + const decoded = jwt.verify(token, JWT_SECRET) as JWTPayload; + return decoded; + } catch (error) { + return null; + } +} + +/** + * Generate a unique user ID + */ +export function generateUserId(): string { + return 'user_' + randomBytes(16).toString('hex'); +} diff --git a/server/utils/database.ts b/server/utils/database.ts new file mode 100644 index 0000000..09f798d --- /dev/null +++ b/server/utils/database.ts @@ -0,0 +1,455 @@ +import { eq, and, like, or, sql, inArray } from 'drizzle-orm'; +import { db } from '../db'; +import * as schema from '../db/schema'; +import { generateUserId } from './auth'; + +export interface PartialSkylander { + id: string; + name: string | null; + link: string | null; + image: string | null; + category: string | null; + game: string | null; + element: string | null; + releasedWith: string | null; + series: string | null; + price: string | null; + links?: { + ebay: string | null; + amazon: string | null; + scl: string | null; + }; +} + +export interface User { + id: string; + email: string; + username: string; + firstName: string | null; + lastName: string | null; + imageUrl: string | null; + bio: string | null; + banned: boolean | null; + createdAt: Date; + lastActiveAt: Date | null; +} + +export interface Settings { + collectionVisibility: boolean | null; + wishlistVisibility: boolean | null; + watchingVisibility: boolean | null; + language: string | null; +} + +export interface DailyMetadata { + id: number; + date: Date; + skylanderId: string | null; +} + +// Skylander operations +export async function fetchPartialSkylanders(): Promise { + const skylanders = await db.select().from(schema.partialSkylanders); + return skylanders.map(mapSkylanderWithLinks); +} + +export async function savePartialSkylander(data: PartialSkylander): Promise { + let category = data.category?.toLowerCase() || ''; + category = category.replaceAll(' ', '-'); + + await db.insert(schema.partialSkylanders).values({ + id: data.id, + name: data.name, + link: data.link, + image: data.image, + category: category, + game: data.game, + element: data.element, + releasedWith: data.releasedWith, + series: data.series, + price: data.price, + ebayLink: data.links?.ebay || null, + amazonLink: data.links?.amazon || null, + sclLink: data.links?.scl || null, + }); +} + +export async function wipePartialSkylanders(): Promise { + await db.delete(schema.partialSkylanders); +} + +export async function fetchPartialSkylander(id: string): Promise { + const result = await db.select() + .from(schema.partialSkylanders) + .where(eq(schema.partialSkylanders.id, id)) + .limit(1); + + return result.length > 0 ? mapSkylanderWithLinks(result[0]) : null; +} + +export async function fetchPartialSkylandersByCategory(category: string): Promise { + const skylanders = await db.select() + .from(schema.partialSkylanders) + .where(eq(schema.partialSkylanders.category, category)); + + return skylanders.map(mapSkylanderWithLinks); +} + +export async function fetchPartialSkylandersByGame(game: string): Promise { + const skylanders = await db.select() + .from(schema.partialSkylanders) + .where(eq(schema.partialSkylanders.game, game)); + + return skylanders.map(mapSkylanderWithLinks); +} + +export async function getPageOfPartials(page: number, filter?: any): Promise { + let query = db.select().from(schema.partialSkylanders); + + if (filter) { + if (filter.game) { + query = query.where(eq(schema.partialSkylanders.game, filter.game)) as any; + } else if (filter.category) { + query = query.where(eq(schema.partialSkylanders.category, filter.category)) as any; + } else if (filter.element) { + query = query.where(eq(schema.partialSkylanders.element, filter.element)) as any; + } + const skylanders = await query; + return skylanders.map(mapSkylanderWithLinks); + } + + const skylanders = await query.limit(25).offset((page - 1) * 25); + return skylanders.map(mapSkylanderWithLinks); +} + +export async function search(term: string, related: boolean = false): Promise { + const searchTerm = term.replaceAll('%20', ' '); + const words = searchTerm.split(' '); + + if (words.length > 1 && related) { + const resultsAll: PartialSkylander[] = []; + for (const word of words) { + const results = await search(word, true); + resultsAll.push(...results); + } + return resultsAll; + } + + const skylanders = await db.select() + .from(schema.partialSkylanders) + .where(like(schema.partialSkylanders.name, `%${searchTerm}%`)); + + return skylanders.map(mapSkylanderWithLinks); +} + +// Daily metadata operations +export async function getDailyMetadata(date: Date): Promise { + const dateNoTime = new Date(date); + dateNoTime.setHours(0, 0, 0, 0); + + const result = await db.select() + .from(schema.dailyMetadata) + .where(eq(schema.dailyMetadata.date, dateNoTime)) + .limit(1); + + if (result.length === 0) { + const skylander = await getRandomSkylander(); + if (!skylander) { + throw new Error('No skylanders found'); + } + + const inserted = await db.insert(schema.dailyMetadata) + .values({ + date: dateNoTime, + skylanderId: skylander.id, + }) + .returning(); + + return inserted[0]; + } + + return result[0]; +} + +async function getRandomSkylander(): Promise { + const skylanders = await fetchPartialSkylanders(); + if (skylanders.length === 0) return null; + const randomIndex = Math.floor(Math.random() * skylanders.length); + return skylanders[randomIndex]; +} + +// User operations +export async function fetchUser(id: string): Promise { + const result = await db.select() + .from(schema.users) + .where(eq(schema.users.id, id)) + .limit(1); + + if (result.length === 0) { + return null; + } + + return result[0]; +} + +export async function createUser(email: string, username: string, passwordHash: string, firstName?: string): Promise { + const userId = generateUserId(); + const now = new Date(); + + const user = await db.insert(schema.users) + .values({ + id: userId, + email, + username, + passwordHash, + firstName: firstName || null, + createdAt: now, + lastActiveAt: now, + }) + .returning(); + + // Create default settings for user + await db.insert(schema.userSettings) + .values({ + userId, + collectionVisibility: true, + wishlistVisibility: true, + watchingVisibility: true, + language: 'english', + }); + + return user[0]; +} + +export async function getUserByEmail(email: string): Promise { + const result = await db.select() + .from(schema.users) + .where(eq(schema.users.email, email)) + .limit(1); + + return result.length > 0 ? result[0] : null; +} + +export async function getUserByUsername(username: string): Promise { + const result = await db.select() + .from(schema.users) + .where(eq(schema.users.username, username)) + .limit(1); + + return result.length > 0 ? result[0] : null; +} + +export async function updateUserBio(userId: string, bio: string): Promise { + await db.update(schema.users) + .set({ bio }) + .where(eq(schema.users.id, userId)); +} + +export async function updateUserLastActive(userId: string): Promise { + await db.update(schema.users) + .set({ lastActiveAt: new Date() }) + .where(eq(schema.users.id, userId)); +} + +// Watching operations +export async function fetchWatchingSkylanders(userId: string, mine: boolean): Promise { + const user = await fetchUser(userId); + if (!user) return []; + + if (!mine) { + const settings = await fetchSettings(userId); + if (!settings.watchingVisibility) return []; + } + + const watching = await db.select({ + skylander: schema.partialSkylanders, + }) + .from(schema.userWatching) + .innerJoin(schema.partialSkylanders, eq(schema.userWatching.skylanderId, schema.partialSkylanders.id)) + .where(eq(schema.userWatching.userId, userId)); + + return watching.map(w => mapSkylanderWithLinks(w.skylander)); +} + +export async function toggleWatchingSkylander(userId: string, skylanderId: string): Promise { + const existing = await db.select() + .from(schema.userWatching) + .where(and( + eq(schema.userWatching.userId, userId), + eq(schema.userWatching.skylanderId, skylanderId) + )) + .limit(1); + + if (existing.length === 0) { + await db.insert(schema.userWatching).values({ userId, skylanderId }); + return true; + } else { + await db.delete(schema.userWatching) + .where(and( + eq(schema.userWatching.userId, userId), + eq(schema.userWatching.skylanderId, skylanderId) + )); + return false; + } +} + +// Collection operations +export async function fetchCollection(userId: string, mine: boolean): Promise { + const user = await fetchUser(userId); + if (!user) return []; + + if (!mine) { + const settings = await fetchSettings(userId); + if (!settings.collectionVisibility) return []; + } + + const figures = await db.select({ + skylander: schema.partialSkylanders, + }) + .from(schema.userFigures) + .innerJoin(schema.partialSkylanders, eq(schema.userFigures.skylanderId, schema.partialSkylanders.id)) + .where(eq(schema.userFigures.userId, userId)); + + return figures.map(f => mapSkylanderWithLinks(f.skylander)); +} + +export async function toggleCollectionSkylander(userId: string, skylanderId: string): Promise { + const existing = await db.select() + .from(schema.userFigures) + .where(and( + eq(schema.userFigures.userId, userId), + eq(schema.userFigures.skylanderId, skylanderId) + )) + .limit(1); + + if (existing.length === 0) { + await db.insert(schema.userFigures).values({ userId, skylanderId }); + return true; + } else { + await db.delete(schema.userFigures) + .where(and( + eq(schema.userFigures.userId, userId), + eq(schema.userFigures.skylanderId, skylanderId) + )); + return false; + } +} + +// Wishlist operations +export async function fetchWishlist(userId: string, mine: boolean): Promise { + const user = await fetchUser(userId); + if (!user) return []; + + if (!mine) { + const settings = await fetchSettings(userId); + if (!settings.wishlistVisibility) return []; + } + + const wishlist = await db.select({ + skylander: schema.partialSkylanders, + }) + .from(schema.userWishlist) + .innerJoin(schema.partialSkylanders, eq(schema.userWishlist.skylanderId, schema.partialSkylanders.id)) + .where(eq(schema.userWishlist.userId, userId)); + + return wishlist.map(w => mapSkylanderWithLinks(w.skylander)); +} + +export async function toggleWishlist(userId: string, skylanderId: string): Promise { + const existing = await db.select() + .from(schema.userWishlist) + .where(and( + eq(schema.userWishlist.userId, userId), + eq(schema.userWishlist.skylanderId, skylanderId) + )) + .limit(1); + + if (existing.length === 0) { + await db.insert(schema.userWishlist).values({ userId, skylanderId }); + return true; + } else { + await db.delete(schema.userWishlist) + .where(and( + eq(schema.userWishlist.userId, userId), + eq(schema.userWishlist.skylanderId, skylanderId) + )); + return false; + } +} + +// Notification/messages operations +export async function fetchMessages(userId: string) { + const messages = await db.select() + .from(schema.notifications) + .where(eq(schema.notifications.userId, userId)); + + return messages; +} + +// Settings operations +export async function fetchSettings(userId: string): Promise { + const result = await db.select() + .from(schema.userSettings) + .where(eq(schema.userSettings.userId, userId)) + .limit(1); + + if (result.length === 0) { + // Create default settings if they don't exist + await db.insert(schema.userSettings).values({ + userId, + collectionVisibility: true, + wishlistVisibility: true, + watchingVisibility: true, + language: 'english', + }); + + return { + collectionVisibility: true, + wishlistVisibility: true, + watchingVisibility: true, + language: 'english', + }; + } + + return result[0]; +} + +export async function modifySetting(userId: string, setting: string, value: any): Promise { + const settings = await db.select() + .from(schema.userSettings) + .where(eq(schema.userSettings.userId, userId)) + .limit(1); + + if (settings.length === 0) { + // Create settings if they don't exist + await db.insert(schema.userSettings).values({ + userId, + [setting]: value, + }); + } else { + await db.update(schema.userSettings) + .set({ [setting]: value }) + .where(eq(schema.userSettings.userId, userId)); + } +} + +// Helper function to map skylander with links +function mapSkylanderWithLinks(skylander: any): PartialSkylander { + return { + id: skylander.id, + name: skylander.name, + link: skylander.link, + image: skylander.image, + category: skylander.category, + game: skylander.game, + element: skylander.element, + releasedWith: skylander.releasedWith || skylander.released_with, + series: skylander.series, + price: skylander.price, + links: { + ebay: skylander.ebayLink || skylander.ebay_link, + amazon: skylander.amazonLink || skylander.amazon_link, + scl: skylander.sclLink || skylander.scl_link, + }, + }; +} diff --git a/utils/database.ts b/utils/database.ts deleted file mode 100644 index d7b4fd6..0000000 --- a/utils/database.ts +++ /dev/null @@ -1,296 +0,0 @@ -import mongoose from "mongoose"; -import DailyMetadata from "~/schemas/dailyMetadata"; -import PartialSkylander from "~/schemas/partialSkylander"; -import { config } from "dotenv"; -import User from "~/schemas/user"; -import { createClerkClient } from "@clerk/backend"; - -export const clerkClient = createClerkClient({ secretKey: process.env.NUXT_CLERK_SECRET_KEY }); - -config(); - -mongoose.connect(process.env.MONGODB_URI || "mongodb://localhost:27017/skylanders"); - -export async function fetchPartialSkylanders(): Promise { - const skylanders: PartialSkylander[] = await PartialSkylander.find(); - return skylanders; -} - -export async function savePartialSkylander( - data: PartialSkylander -): Promise { - data.category = data.category.toLowerCase(); - data.category = data.category.replaceAll(" ", "-"); - const skylander = new PartialSkylander(data); - await skylander.save(); -} - -export async function wipePartialSkylanders(): Promise { - await PartialSkylander.deleteMany({}); -} - -export async function fetchPartialSkylander( - id: string -): Promise { - const skylander: PartialSkylander | null = await PartialSkylander.findOne({ - _id: id, - }); - - return skylander; -} - -export async function fetchPartialSkylandersByCategory( - category: Category -): Promise { - const skylanders: PartialSkylander[] = await PartialSkylander.find({ - category, - }); - return skylanders; -} - -export async function fetchPartialSkylandersByGame( - game: Game -): Promise { - const skylanders: PartialSkylander[] = await PartialSkylander.find({ game }); - return skylanders; -} - -async function getRandomSkylander(): Promise { - const skylanders: PartialSkylander[] = await fetchPartialSkylanders(); - const randomIndex = Math.floor(Math.random() * skylanders.length); - return skylanders[randomIndex]; -} - -export async function getDailyMetadata(date: Date): Promise { - const dateNoTime = new Date(date); - dateNoTime.setHours(0, 0, 0, 0); - - const metadata: DailyMetadata | null = await DailyMetadata.findOne({ - date: dateNoTime, - }); - - - if (metadata === null) { - const skylander = await getRandomSkylander(); - if (!skylander) { - throw new Error("No skylanders found"); - } - - const newMetadata = new DailyMetadata({ date: dateNoTime, skylander: skylander._id }); - await newMetadata.save(); - - // @ts-ignore - return newMetadata as DailyMetadata; - } - - return metadata; -} - -// this function SUCKS!! -export async function getPageOfPartials(page: number, filter?: Object): Promise { - if (filter) { - // @ts-ignore - if (filter["game"]) { - // @ts-ignore - const skylanders: PartialSkylander[] = await PartialSkylander.find({ game: filter["game"] }) - return skylanders; - // @ts-ignore - } else if (filter["category"]) { - // @ts-ignore - const skylanders: PartialSkylander[] = await PartialSkylander.find({ category: filter["category"] }) - return skylanders; - // @ts-ignore - } else if (filter["element"]) { - // @ts-ignore - const skylanders: PartialSkylander[] = await PartialSkylander.find({ element: filter["element"] }) - return skylanders; - } - } - - // @ts-ignore - const skylanders: PartialSkylander[] = await PartialSkylander.find().skip((page - 1) * 25).limit(25); - return skylanders; -} - -export async function search(term: String, related: Boolean): Promise { - const words = term.replaceAll("%20", " ").split(" "); - if (words.length > 1 && related) { - const resultsAll: PartialSkylander[] = []; - for (const word of words) { - const results = await search(word, true); - resultsAll.push(...results); - } - return resultsAll; - } - - const skylanders: PartialSkylander[] = await PartialSkylander.find({ - name: { $regex: term, $options: "i" }, - }); - return skylanders; -} - -export async function fetchUser(id: string): Promise { - const user: User | null = await User.findOne({ id }); - if (!user) { - console.log(id) - const clerkUser = await clerkClient.users.getUser(id) - if (!clerkUser) { - return null; - }; - const newUser = new User({ id, wishlist: [], figures: [], watching: [], notifications: [] }); - await newUser.save(); - - // @ts-ignore - return newUser as User; - } - - return user; -} - -export async function fetchWatchingSkylanders(userId: string, mine: boolean): Promise { - const user = await fetchUser(userId); - if (!user) { - return []; - } - - if (!mine && user.settings.watchingVisibility === false) { - return []; - } - - - const skylanders: PartialSkylander[] = await PartialSkylander.find({ - _id: { $in: user.watching }, - }); - return skylanders; -} - -export async function toggleWatchingSkylander(userId: string, skylanderId: string): Promise { - const user = await fetchUser(userId); - if (!user) { - throw new Error("User not found"); - } - - let result - - const index = user.watching.indexOf(skylanderId); - if (index === -1) { - user.watching.push(skylanderId); - result = true; - - } else { - user.watching.splice(index, 1); - result = false; - } - - // @ts-ignore - await user.save(); - return result; -} - -export async function toggleCollectionSkylander(userId: string, skylanderId: string): Promise { - const user = await fetchUser(userId); - if (!user) { - throw new Error("User not found"); - } - - let result - - const index = user.figures.indexOf(skylanderId); - if (index === -1) { - user.figures.push(skylanderId); - result = true; - - } else { - user.figures.splice(index, 1); - result = false; - } - - // @ts-ignore - await user.save(); - return result; -} - -export async function fetchMessages(userId: string) { - const user = await fetchUser(userId); - if (!user) { - return []; - } - - return user.notifications; -} - -export async function fetchCollection(userId: string, mine: boolean): Promise { - const user = await fetchUser(userId); - if (!user) { - return []; - } - - if (!mine && user.settings.collectionVisibility === false) { - return []; - } - - const skylanders: PartialSkylander[] = await PartialSkylander.find({ - _id: { $in: user.figures }, - }); - return skylanders; -} - -export async function toggleWishlist(userId: string, skylanderId: string): Promise { - const user = await fetchUser(userId); - if (!user) { - throw new Error("User not found"); - } - - let result - - const index = user.wishlist.indexOf(skylanderId); - if (index === -1) { - user.wishlist.push(skylanderId); - result = true; - - } else { - user.wishlist.splice(index, 1); - result = false; - } - - // @ts-ignore - await user.save(); - return result; -} - -export async function fetchWishlist(userId: string, mine: boolean): Promise { - const user = await fetchUser(userId); - if (!user) { - return []; - } - - if (!mine && user.settings.wishlistVisibility === false) { - return []; - } - - const skylanders: PartialSkylander[] = await PartialSkylander.find({ - _id: { $in: user.wishlist }, - }); - return skylanders; -} - -export async function fetchSettings(userId: string): Promise { - const user = await fetchUser(userId); - if (!user) { - throw new Error("User not found"); - } - - return user.settings; -} - -export async function modifySetting(userId: string, setting: string, value: any): Promise { - const user = await fetchUser(userId); - if (!user) { - throw new Error("User not found"); - } - - // @ts-ignore - user.settings[setting] = value; - // @ts-ignore - await user.save(); -} \ No newline at end of file