algocoach 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/cli/index.ts +97 -0
- package/dist/assets/index-C4ELaZwf.css +1 -0
- package/dist/assets/index-D5iezweF.js +55 -0
- package/dist/favicon.svg +11 -0
- package/dist/icons.svg +24 -0
- package/dist/index.html +17 -0
- package/package.json +59 -0
- package/server/auth/index.ts +14 -0
- package/server/db/custom-types.ts +13 -0
- package/server/db/index.ts +32 -0
- package/server/db/schema.ts +130 -0
- package/server/db/setup.ts +144 -0
- package/server/index.ts +57 -0
- package/server/lib/validation.test.ts +91 -0
- package/server/lib/validation.ts +25 -0
- package/server/local-dev/index.ts +5 -0
- package/server/local-dev/test-ai.ts +88 -0
- package/server/middleware/auth.ts +30 -0
- package/server/middleware/rate-limit.test.ts +77 -0
- package/server/middleware/rate-limit.ts +30 -0
- package/server/routes/leetcode.ts +189 -0
- package/server/routes/onboard.ts +75 -0
- package/server/routes/plan.ts +595 -0
- package/server/routes/survey.ts +44 -0
- package/server/services/ai-provider.ts +171 -0
- package/server/services/ai.test.ts +61 -0
- package/server/services/ai.ts +368 -0
- package/server/services/leetcode-search.test.ts +85 -0
- package/server/services/leetcode-search.ts +84 -0
- package/server/services/leetcode.ts +84 -0
package/dist/favicon.svg
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" stop-color="#6366f1"/>
|
|
5
|
+
<stop offset="100%" stop-color="#7c3aed"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="32" height="32" rx="8" fill="url(#bg)"/>
|
|
9
|
+
<path d="M8 20l4-8 4 8" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
|
10
|
+
<path d="M20 12h4v8M20 16h3" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
|
11
|
+
</svg>
|
package/dist/icons.svg
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
3
|
+
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
4
|
+
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
5
|
+
</symbol>
|
|
6
|
+
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
7
|
+
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
8
|
+
</symbol>
|
|
9
|
+
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
10
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
11
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
12
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
13
|
+
</symbol>
|
|
14
|
+
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
15
|
+
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
16
|
+
</symbol>
|
|
17
|
+
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
18
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
19
|
+
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
20
|
+
</symbol>
|
|
21
|
+
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
22
|
+
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
23
|
+
</symbol>
|
|
24
|
+
</svg>
|
package/dist/index.html
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>AlgoCoach - Personalized LeetCode Practice</title>
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
10
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-D5iezweF.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C4ELaZwf.css">
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<div id="root"></div>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "algocoach",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"algocoach": "cli/index.ts"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"cli/",
|
|
10
|
+
"dist/",
|
|
11
|
+
"server/"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"dev": "concurrently \"bun run dev:client\" \"bun run dev:server\"",
|
|
15
|
+
"dev:client": "vite",
|
|
16
|
+
"dev:server": "bun run --hot cli/index.ts serve",
|
|
17
|
+
"build": "tsc -b && vite build",
|
|
18
|
+
"prepack": "vite build",
|
|
19
|
+
"lint": "eslint .",
|
|
20
|
+
"test": "bun test",
|
|
21
|
+
"test:watch": "bun test --watch"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@better-auth/drizzle-adapter": "^1.6.15",
|
|
25
|
+
"@google/genai": "^2.8.0",
|
|
26
|
+
"autoprefixer": "^10.5.0",
|
|
27
|
+
"better-auth": "^1.6.15",
|
|
28
|
+
"drizzle-orm": "^0.45.2",
|
|
29
|
+
"framer-motion": "^12.40.0",
|
|
30
|
+
"groq-sdk": "^1.2.1",
|
|
31
|
+
"hono": "^4.12.25",
|
|
32
|
+
"lucide-react": "^1.17.0",
|
|
33
|
+
"openai": "^6.43.0",
|
|
34
|
+
"postcss": "^8.5.15",
|
|
35
|
+
"react": "^19.2.6",
|
|
36
|
+
"react-dom": "^19.2.6",
|
|
37
|
+
"react-router-dom": "^7.17.0",
|
|
38
|
+
"recharts": "^3.8.1",
|
|
39
|
+
"tailwindcss": "3",
|
|
40
|
+
"zod": "^4.4.3"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@eslint/js": "^10.0.1",
|
|
44
|
+
"@types/node": "^25.9.2",
|
|
45
|
+
"@types/react": "^19.2.14",
|
|
46
|
+
"@types/react-dom": "^19.2.3",
|
|
47
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
48
|
+
"bun-types": "^1.3.14",
|
|
49
|
+
"concurrently": "^10.0.3",
|
|
50
|
+
"drizzle-kit": "^0.31.10",
|
|
51
|
+
"eslint": "^10.3.0",
|
|
52
|
+
"eslint-plugin-react-hooks": "^7.1.1",
|
|
53
|
+
"eslint-plugin-react-refresh": "^0.5.2",
|
|
54
|
+
"globals": "^17.6.0",
|
|
55
|
+
"typescript": "~6.0.2",
|
|
56
|
+
"typescript-eslint": "^8.59.2",
|
|
57
|
+
"vite": "^8.0.12"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { betterAuth } from 'better-auth'
|
|
2
|
+
import { drizzleAdapter } from '@better-auth/drizzle-adapter'
|
|
3
|
+
import { db } from '../db'
|
|
4
|
+
import * as schema from '../db/schema'
|
|
5
|
+
|
|
6
|
+
const productionUrl = process.env.BETTER_AUTH_URL || ''
|
|
7
|
+
|
|
8
|
+
export const auth = betterAuth({
|
|
9
|
+
database: drizzleAdapter(db, {
|
|
10
|
+
provider: 'sqlite',
|
|
11
|
+
schema,
|
|
12
|
+
}),
|
|
13
|
+
trustedOrigins: [productionUrl, 'http://localhost:5173', 'http://localhost:3000'],
|
|
14
|
+
})
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { customType } from "drizzle-orm/sqlite-core"
|
|
2
|
+
|
|
3
|
+
export const jsonText = <T>() => customType<{ data: T; driverData: string }>({
|
|
4
|
+
dataType: () => "text",
|
|
5
|
+
toDriver: (value: T) => JSON.stringify(value),
|
|
6
|
+
fromDriver: (value: string) => JSON.parse(value) as T,
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
export const textArray = () => customType<{ data: string[]; driverData: string }>({
|
|
10
|
+
dataType: () => "text",
|
|
11
|
+
toDriver: (value: string[]) => JSON.stringify(value),
|
|
12
|
+
fromDriver: (value: string) => JSON.parse(value) as string[],
|
|
13
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { drizzle } from "drizzle-orm/bun-sqlite"
|
|
3
|
+
import * as schema from "./schema"
|
|
4
|
+
import { createTables } from "./setup"
|
|
5
|
+
import path from "path"
|
|
6
|
+
import fs from "fs"
|
|
7
|
+
|
|
8
|
+
function getDbPath(): string {
|
|
9
|
+
const home = process.env.HOME || process.env.USERPROFILE || "."
|
|
10
|
+
const dir = path.join(home, ".algocoach")
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
12
|
+
return path.join(dir, "data.db")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function createDb() {
|
|
16
|
+
const dbPath = getDbPath()
|
|
17
|
+
const sqlite = new Database(dbPath)
|
|
18
|
+
createTables(sqlite)
|
|
19
|
+
return drizzle(sqlite, { schema })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let _db: ReturnType<typeof createDb> | null = null
|
|
23
|
+
export function getDb() {
|
|
24
|
+
if (!_db) _db = createDb()
|
|
25
|
+
return _db
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const db = new Proxy({} as ReturnType<typeof createDb>, {
|
|
29
|
+
get(_, prop) {
|
|
30
|
+
return getDb()[prop as keyof ReturnType<typeof createDb>]
|
|
31
|
+
},
|
|
32
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sqliteTable,
|
|
3
|
+
text as sqliteText,
|
|
4
|
+
integer,
|
|
5
|
+
} from "drizzle-orm/sqlite-core"
|
|
6
|
+
import { jsonText, textArray } from "./custom-types"
|
|
7
|
+
|
|
8
|
+
const text = sqliteText
|
|
9
|
+
|
|
10
|
+
export const user = sqliteTable("user", {
|
|
11
|
+
id: text("id").primaryKey(),
|
|
12
|
+
name: text("name"),
|
|
13
|
+
linkedinUserId: text("linkedin_user_id"),
|
|
14
|
+
email: text("email").notNull().unique(),
|
|
15
|
+
emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false),
|
|
16
|
+
image: text("image"),
|
|
17
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
18
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const session = sqliteTable("session", {
|
|
22
|
+
id: text("id").primaryKey(),
|
|
23
|
+
userId: text("user_id").notNull().references(() => user.id),
|
|
24
|
+
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
|
25
|
+
token: text("token").notNull().unique(),
|
|
26
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
27
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
28
|
+
ipAddress: text("ip_address"),
|
|
29
|
+
userAgent: text("user_agent"),
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const account = sqliteTable("account", {
|
|
33
|
+
id: text("id").primaryKey(),
|
|
34
|
+
userId: text("user_id").notNull().references(() => user.id),
|
|
35
|
+
accountId: text("account_id").notNull(),
|
|
36
|
+
providerId: text("provider_id").notNull(),
|
|
37
|
+
accessToken: text("access_token"),
|
|
38
|
+
refreshToken: text("refresh_token"),
|
|
39
|
+
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp_ms" }),
|
|
40
|
+
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp_ms" }),
|
|
41
|
+
scope: text("scope"),
|
|
42
|
+
idToken: text("id_token"),
|
|
43
|
+
password: text("password"),
|
|
44
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
45
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
export const verification = sqliteTable("verification", {
|
|
49
|
+
id: text("id").primaryKey(),
|
|
50
|
+
identifier: text("identifier").notNull(),
|
|
51
|
+
value: text("value").notNull(),
|
|
52
|
+
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
|
|
53
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
54
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
export const surveyResponse = sqliteTable("survey_response", {
|
|
58
|
+
id: text("id").primaryKey(),
|
|
59
|
+
email: text("email").notNull(),
|
|
60
|
+
struggles: text("struggles").notNull(),
|
|
61
|
+
desiredFeature: text("desired_feature").notNull(),
|
|
62
|
+
goals: text("goals").notNull(),
|
|
63
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
export const leetcodeAccount = sqliteTable("leetcode_account", {
|
|
67
|
+
id: text("id").primaryKey(),
|
|
68
|
+
userId: text("user_id").unique().references(() => user.id),
|
|
69
|
+
leetcodeUsername: text("leetcode_username").notNull(),
|
|
70
|
+
totalSolved: integer("total_solved").notNull().default(0),
|
|
71
|
+
easySolved: integer("easy_solved").notNull().default(0),
|
|
72
|
+
mediumSolved: integer("medium_solved").notNull().default(0),
|
|
73
|
+
hardSolved: integer("hard_solved").notNull().default(0),
|
|
74
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
75
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
export const dailyProgress = sqliteTable("daily_progress", {
|
|
79
|
+
id: text("id").primaryKey(),
|
|
80
|
+
userId: text("user_id").references(() => user.id),
|
|
81
|
+
date: integer("date", { mode: "timestamp_ms" }).notNull(),
|
|
82
|
+
problemName: text("problem_name").notNull(),
|
|
83
|
+
difficulty: text("difficulty").notNull(),
|
|
84
|
+
problemId: text("problem_id").notNull(),
|
|
85
|
+
topics: textArray()("topics").notNull(),
|
|
86
|
+
status: text("status").notNull().default("IN_PROGRESS"),
|
|
87
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
export const userPreferences = sqliteTable("user_preferences", {
|
|
91
|
+
userId: text("user_id").primaryKey().references(() => user.id),
|
|
92
|
+
experienceLevel: text("experience_level").notNull(),
|
|
93
|
+
goals: textArray()("goals").notNull(),
|
|
94
|
+
weakTopics: textArray()("weak_topics").notNull(),
|
|
95
|
+
targetCompanies: textArray()("target_companies"),
|
|
96
|
+
hoursPerWeek: integer("hours_per_week").notNull(),
|
|
97
|
+
targetDate: integer("target_date", { mode: "timestamp_ms" }),
|
|
98
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
99
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
export const roadmapPlan = sqliteTable("roadmap_plan", {
|
|
103
|
+
id: text("id").primaryKey(),
|
|
104
|
+
userId: text("user_id").notNull().unique().references(() => user.id),
|
|
105
|
+
weeks: jsonText<{ week: number; topic: string; description: string; problemsCount: number }[]>()("weeks").notNull(),
|
|
106
|
+
currentWeek: integer("current_week").notNull().default(1),
|
|
107
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
108
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
export const dailyPlan = sqliteTable("daily_plan", {
|
|
112
|
+
id: text("id").primaryKey(),
|
|
113
|
+
userId: text("user_id").notNull().references(() => user.id),
|
|
114
|
+
date: integer("date", { mode: "timestamp_ms" }).notNull(),
|
|
115
|
+
weekNumber: integer("week_number").notNull(),
|
|
116
|
+
topic: text("topic").notNull(),
|
|
117
|
+
problems: jsonText<{ title: string; titleSlug: string; difficulty: string; topicTags: string[]; leetcodeUrl: string; acRate: number; status?: string; completedAt?: string | null }[]>()("problems").notNull(),
|
|
118
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
export const roadmapJob = sqliteTable("roadmap_job", {
|
|
122
|
+
id: text("id").primaryKey(),
|
|
123
|
+
userId: text("user_id").notNull().references(() => user.id),
|
|
124
|
+
status: text("status").notNull().default("pending"),
|
|
125
|
+
progress: text("progress"),
|
|
126
|
+
result: jsonText<{ week: number; topic: string; description: string; problemsCount: number }[]>()("result"),
|
|
127
|
+
error: text("error"),
|
|
128
|
+
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
129
|
+
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
|
|
130
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
export function createTables(db: Database) {
|
|
4
|
+
db.run("PRAGMA journal_mode = WAL")
|
|
5
|
+
db.run("PRAGMA foreign_keys = ON")
|
|
6
|
+
|
|
7
|
+
db.run(`
|
|
8
|
+
CREATE TABLE IF NOT EXISTS "user" (
|
|
9
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
10
|
+
"name" text,
|
|
11
|
+
"linkedin_user_id" text,
|
|
12
|
+
"email" text NOT NULL UNIQUE,
|
|
13
|
+
"email_verified" integer NOT NULL DEFAULT false,
|
|
14
|
+
"image" text,
|
|
15
|
+
"created_at" integer NOT NULL,
|
|
16
|
+
"updated_at" integer NOT NULL
|
|
17
|
+
)
|
|
18
|
+
`)
|
|
19
|
+
db.run(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS "session" (
|
|
21
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
22
|
+
"user_id" text NOT NULL REFERENCES "user"("id"),
|
|
23
|
+
"expires_at" integer NOT NULL,
|
|
24
|
+
"token" text NOT NULL UNIQUE,
|
|
25
|
+
"created_at" integer NOT NULL,
|
|
26
|
+
"updated_at" integer NOT NULL,
|
|
27
|
+
"ip_address" text,
|
|
28
|
+
"user_agent" text
|
|
29
|
+
)
|
|
30
|
+
`)
|
|
31
|
+
db.run(`
|
|
32
|
+
CREATE TABLE IF NOT EXISTS "account" (
|
|
33
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
34
|
+
"user_id" text NOT NULL REFERENCES "user"("id"),
|
|
35
|
+
"account_id" text NOT NULL,
|
|
36
|
+
"provider_id" text NOT NULL,
|
|
37
|
+
"access_token" text,
|
|
38
|
+
"refresh_token" text,
|
|
39
|
+
"access_token_expires_at" integer,
|
|
40
|
+
"refresh_token_expires_at" integer,
|
|
41
|
+
"scope" text,
|
|
42
|
+
"id_token" text,
|
|
43
|
+
"password" text,
|
|
44
|
+
"created_at" integer NOT NULL,
|
|
45
|
+
"updated_at" integer NOT NULL
|
|
46
|
+
)
|
|
47
|
+
`)
|
|
48
|
+
db.run(`
|
|
49
|
+
CREATE TABLE IF NOT EXISTS "verification" (
|
|
50
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
51
|
+
"identifier" text NOT NULL,
|
|
52
|
+
"value" text NOT NULL,
|
|
53
|
+
"expires_at" integer NOT NULL,
|
|
54
|
+
"created_at" integer NOT NULL,
|
|
55
|
+
"updated_at" integer NOT NULL
|
|
56
|
+
)
|
|
57
|
+
`)
|
|
58
|
+
db.run(`
|
|
59
|
+
CREATE TABLE IF NOT EXISTS "survey_response" (
|
|
60
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
61
|
+
"email" text NOT NULL,
|
|
62
|
+
"struggles" text NOT NULL,
|
|
63
|
+
"desired_feature" text NOT NULL,
|
|
64
|
+
"goals" text NOT NULL,
|
|
65
|
+
"created_at" integer NOT NULL
|
|
66
|
+
)
|
|
67
|
+
`)
|
|
68
|
+
db.run(`
|
|
69
|
+
CREATE TABLE IF NOT EXISTS "leetcode_account" (
|
|
70
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
71
|
+
"user_id" text UNIQUE REFERENCES "user"("id"),
|
|
72
|
+
"leetcode_username" text NOT NULL,
|
|
73
|
+
"total_solved" integer NOT NULL DEFAULT 0,
|
|
74
|
+
"easy_solved" integer NOT NULL DEFAULT 0,
|
|
75
|
+
"medium_solved" integer NOT NULL DEFAULT 0,
|
|
76
|
+
"hard_solved" integer NOT NULL DEFAULT 0,
|
|
77
|
+
"created_at" integer NOT NULL,
|
|
78
|
+
"updated_at" integer NOT NULL
|
|
79
|
+
)
|
|
80
|
+
`)
|
|
81
|
+
db.run(`
|
|
82
|
+
CREATE TABLE IF NOT EXISTS "daily_progress" (
|
|
83
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
84
|
+
"user_id" text REFERENCES "user"("id"),
|
|
85
|
+
"date" integer NOT NULL,
|
|
86
|
+
"problem_name" text NOT NULL,
|
|
87
|
+
"difficulty" text NOT NULL,
|
|
88
|
+
"problem_id" text NOT NULL,
|
|
89
|
+
"topics" text NOT NULL,
|
|
90
|
+
"status" text NOT NULL DEFAULT 'IN_PROGRESS',
|
|
91
|
+
"created_at" integer NOT NULL
|
|
92
|
+
)
|
|
93
|
+
`)
|
|
94
|
+
db.run(`
|
|
95
|
+
CREATE TABLE IF NOT EXISTS "user_preferences" (
|
|
96
|
+
"user_id" text PRIMARY KEY NOT NULL REFERENCES "user"("id"),
|
|
97
|
+
"experience_level" text NOT NULL,
|
|
98
|
+
"goals" text NOT NULL,
|
|
99
|
+
"weak_topics" text NOT NULL,
|
|
100
|
+
"target_companies" text,
|
|
101
|
+
"hours_per_week" integer NOT NULL,
|
|
102
|
+
"target_date" integer,
|
|
103
|
+
"created_at" integer NOT NULL,
|
|
104
|
+
"updated_at" integer NOT NULL
|
|
105
|
+
)
|
|
106
|
+
`)
|
|
107
|
+
db.run(`
|
|
108
|
+
CREATE TABLE IF NOT EXISTS "roadmap_plan" (
|
|
109
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
110
|
+
"user_id" text NOT NULL UNIQUE REFERENCES "user"("id"),
|
|
111
|
+
"weeks" text NOT NULL,
|
|
112
|
+
"current_week" integer NOT NULL DEFAULT 1,
|
|
113
|
+
"created_at" integer NOT NULL,
|
|
114
|
+
"updated_at" integer NOT NULL
|
|
115
|
+
)
|
|
116
|
+
`)
|
|
117
|
+
db.run(`
|
|
118
|
+
CREATE TABLE IF NOT EXISTS "daily_plan" (
|
|
119
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
120
|
+
"user_id" text NOT NULL REFERENCES "user"("id"),
|
|
121
|
+
"date" integer NOT NULL,
|
|
122
|
+
"week_number" integer NOT NULL,
|
|
123
|
+
"topic" text NOT NULL,
|
|
124
|
+
"problems" text NOT NULL,
|
|
125
|
+
"created_at" integer NOT NULL
|
|
126
|
+
)
|
|
127
|
+
`)
|
|
128
|
+
db.run(`
|
|
129
|
+
CREATE TABLE IF NOT EXISTS "roadmap_job" (
|
|
130
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
131
|
+
"user_id" text NOT NULL REFERENCES "user"("id"),
|
|
132
|
+
"status" text NOT NULL DEFAULT 'pending',
|
|
133
|
+
"progress" text,
|
|
134
|
+
"result" text,
|
|
135
|
+
"error" text,
|
|
136
|
+
"created_at" integer NOT NULL,
|
|
137
|
+
"updated_at" integer NOT NULL
|
|
138
|
+
)
|
|
139
|
+
`)
|
|
140
|
+
|
|
141
|
+
// Add unique indexes for tables that might already exist without them
|
|
142
|
+
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_leetcode_account_user_id ON leetcode_account(user_id)")
|
|
143
|
+
db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_roadmap_plan_user_id ON roadmap_plan(user_id)")
|
|
144
|
+
}
|
package/server/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import { cors } from 'hono/cors'
|
|
3
|
+
import { serveStatic } from 'hono/bun'
|
|
4
|
+
import { auth } from './auth'
|
|
5
|
+
import { rateLimit } from './middleware/rate-limit'
|
|
6
|
+
import surveyRoutes from './routes/survey'
|
|
7
|
+
import leetcodeRoutes from './routes/leetcode'
|
|
8
|
+
import onboardRoutes from './routes/onboard'
|
|
9
|
+
import planRoutes from './routes/plan'
|
|
10
|
+
import path from 'path'
|
|
11
|
+
import fs from 'fs'
|
|
12
|
+
|
|
13
|
+
const app = new Hono()
|
|
14
|
+
|
|
15
|
+
const productionUrl = process.env.BETTER_AUTH_URL || ''
|
|
16
|
+
|
|
17
|
+
app.use('/*', cors({
|
|
18
|
+
origin: ['http://localhost:5173', 'http://localhost:3000', productionUrl],
|
|
19
|
+
credentials: true,
|
|
20
|
+
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
21
|
+
allowHeaders: ['Content-Type', 'Authorization'],
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
app.use('/api/survey', rateLimit(10, 60000))
|
|
25
|
+
app.use('/api/leetcode/link', rateLimit(5, 60000))
|
|
26
|
+
app.use('/api/leetcode/log', rateLimit(30, 60000))
|
|
27
|
+
app.use('/api/leetcode/refresh', rateLimit(10, 60000))
|
|
28
|
+
|
|
29
|
+
app.all('/api/auth/*', async (c) => {
|
|
30
|
+
return auth.handler(c.req.raw)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
app.route('/api/survey', surveyRoutes)
|
|
34
|
+
app.route('/api/leetcode', leetcodeRoutes)
|
|
35
|
+
app.route('/api/onboard', onboardRoutes)
|
|
36
|
+
app.route('/api/plan', planRoutes)
|
|
37
|
+
|
|
38
|
+
app.get('/api/health', (c) => c.json({ status: 'ok', timestamp: new Date().toISOString() }))
|
|
39
|
+
|
|
40
|
+
const distPath = path.resolve(import.meta.dir, "..", "dist")
|
|
41
|
+
if (fs.existsSync(distPath)) {
|
|
42
|
+
app.use('/assets/*', serveStatic({ root: distPath }))
|
|
43
|
+
app.use('/favicon.svg', serveStatic({ root: distPath }))
|
|
44
|
+
app.use('/icons.svg', serveStatic({ root: distPath }))
|
|
45
|
+
app.get('*', async (c) => {
|
|
46
|
+
if (c.req.path.startsWith('/api/')) return c.text('Not found', 404)
|
|
47
|
+
const file = Bun.file(path.join(distPath, 'index.html'))
|
|
48
|
+
if (await file.exists()) return new Response(file, { headers: { 'Content-Type': 'text/html' } })
|
|
49
|
+
return c.text('Not found', 404)
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function serve(port: number = 3000) {
|
|
54
|
+
Bun.serve({ fetch: app.fetch, port, idleTimeout: 255 })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default app
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test"
|
|
2
|
+
import { surveySchema, linkLeetcodeSchema, logProblemSchema } from "./validation"
|
|
3
|
+
|
|
4
|
+
describe("surveySchema", () => {
|
|
5
|
+
test("accepts valid survey", () => {
|
|
6
|
+
const result = surveySchema.parse({ email: "test@example.com" })
|
|
7
|
+
expect(result.email).toBe("test@example.com")
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test("accepts survey with all fields", () => {
|
|
11
|
+
const result = surveySchema.parse({
|
|
12
|
+
email: "test@example.com",
|
|
13
|
+
struggles: ["consistency", "dp"],
|
|
14
|
+
desiredFeature: "more problems",
|
|
15
|
+
goals: ["faang"],
|
|
16
|
+
})
|
|
17
|
+
expect(result.struggles).toEqual(["consistency", "dp"])
|
|
18
|
+
expect(result.desiredFeature).toBe("more problems")
|
|
19
|
+
expect(result.goals).toEqual(["faang"])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test("rejects invalid email", () => {
|
|
23
|
+
expect(() => surveySchema.parse({ email: "not-an-email" })).toThrow()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("rejects empty email", () => {
|
|
27
|
+
expect(() => surveySchema.parse({ email: "" })).toThrow()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("defaults optional fields when omitted", () => {
|
|
31
|
+
const result = surveySchema.parse({ email: "test@example.com" })
|
|
32
|
+
expect(result.struggles).toBeUndefined()
|
|
33
|
+
expect(result.desiredFeature).toBeUndefined()
|
|
34
|
+
expect(result.goals).toBeUndefined()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe("linkLeetcodeSchema", () => {
|
|
39
|
+
test("accepts valid username", () => {
|
|
40
|
+
const result = linkLeetcodeSchema.parse({ leetcodeUsername: "neetcode" })
|
|
41
|
+
expect(result.leetcodeUsername).toBe("neetcode")
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("rejects empty username", () => {
|
|
45
|
+
expect(() => linkLeetcodeSchema.parse({ leetcodeUsername: "" })).toThrow()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("accepts username with numbers", () => {
|
|
49
|
+
const result = linkLeetcodeSchema.parse({ leetcodeUsername: "user123" })
|
|
50
|
+
expect(result.leetcodeUsername).toBe("user123")
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe("logProblemSchema", () => {
|
|
55
|
+
test("accepts valid log entry", () => {
|
|
56
|
+
const result = logProblemSchema.parse({
|
|
57
|
+
problemId: "two-sum",
|
|
58
|
+
problemName: "Two Sum",
|
|
59
|
+
difficulty: "Medium",
|
|
60
|
+
})
|
|
61
|
+
expect(result.problemId).toBe("two-sum")
|
|
62
|
+
expect(result.difficulty).toBe("Medium")
|
|
63
|
+
expect(result.topics).toEqual([])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test("accepts log entry with topics", () => {
|
|
67
|
+
const result = logProblemSchema.parse({
|
|
68
|
+
problemId: "two-sum",
|
|
69
|
+
problemName: "Two Sum",
|
|
70
|
+
difficulty: "Easy",
|
|
71
|
+
topics: ["array", "hash-table"],
|
|
72
|
+
})
|
|
73
|
+
expect(result.topics).toEqual(["array", "hash-table"])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test("rejects invalid difficulty", () => {
|
|
77
|
+
expect(() => logProblemSchema.parse({
|
|
78
|
+
problemId: "test",
|
|
79
|
+
problemName: "Test",
|
|
80
|
+
difficulty: "SuperHard",
|
|
81
|
+
})).toThrow()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("rejects empty problemId", () => {
|
|
85
|
+
expect(() => logProblemSchema.parse({
|
|
86
|
+
problemId: "",
|
|
87
|
+
problemName: "Test",
|
|
88
|
+
difficulty: "Easy",
|
|
89
|
+
})).toThrow()
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
export const experienceEnum = z.enum(['beginner', 'intermediate', 'advanced', 'competitive'])
|
|
4
|
+
|
|
5
|
+
export const surveySchema = z.object({
|
|
6
|
+
email: z.string().email(),
|
|
7
|
+
struggles: z.array(z.string()).optional(),
|
|
8
|
+
desiredFeature: z.string().max(1000).optional(),
|
|
9
|
+
goals: z.array(z.string()).optional(),
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
export const linkLeetcodeSchema = z.object({
|
|
13
|
+
leetcodeUsername: z.string().min(1, 'LeetCode username is required').max(50),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export const logProblemSchema = z.object({
|
|
17
|
+
problemId: z.string().min(1),
|
|
18
|
+
problemName: z.string().min(1),
|
|
19
|
+
difficulty: z.enum(['Easy', 'Medium', 'Hard']),
|
|
20
|
+
topics: z.array(z.string()).default([]),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export type SurveyInput = z.infer<typeof surveySchema>
|
|
24
|
+
export type LinkLeetcodeInput = z.infer<typeof linkLeetcodeSchema>
|
|
25
|
+
export type LogProblemInput = z.infer<typeof logProblemSchema>
|