This commit is contained in:
@@ -4,16 +4,17 @@ import { generateMemorableToken, isValidToken } from '@/lib/auth/tokens';
|
||||
import { setTokenCookie, getTokenCookie } from '@/lib/auth/cookies';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
export async function GET() {
|
||||
try {
|
||||
// Check if user already has a token
|
||||
const existingToken = await getTokenCookie();
|
||||
|
||||
if (existingToken) {
|
||||
// Verify token exists in database
|
||||
const [user] = await db.select().from(users).where(eq(users.token, existingToken));
|
||||
const userRows = await db.select().from(users).where(eq(users.token, existingToken));
|
||||
|
||||
if (user) {
|
||||
if (userRows.length > 0) {
|
||||
const user = userRows[0];
|
||||
return NextResponse.json({
|
||||
authenticated: true,
|
||||
token: existingToken,
|
||||
@@ -31,20 +32,25 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const body = (await request.json()) as { action: string; token?: string };
|
||||
const { action, token } = body;
|
||||
|
||||
if (action === 'create') {
|
||||
// Generate new token and create user
|
||||
const newToken = generateMemorableToken();
|
||||
|
||||
const [newUser] = await db
|
||||
const newUserRows = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
token: newToken,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (newUserRows.length === 0) {
|
||||
throw new Error('Failed to create user');
|
||||
}
|
||||
|
||||
const newUser = newUserRows[0];
|
||||
await setTokenCookie(newToken);
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -67,9 +73,9 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Check if token exists
|
||||
const [user] = await db.select().from(users).where(eq(users.token, token));
|
||||
const userRows = await db.select().from(users).where(eq(users.token, token));
|
||||
|
||||
if (!user) {
|
||||
if (userRows.length === 0) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
@@ -79,6 +85,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const user = userRows[0];
|
||||
await setTokenCookie(token);
|
||||
|
||||
return NextResponse.json({
|
||||
|
@@ -7,8 +7,8 @@ async function getUserFromToken() {
|
||||
const token = await getTokenCookie();
|
||||
if (!token) return null;
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.token, token));
|
||||
return user;
|
||||
const userRows = await db.select().from(users).where(eq(users.token, token));
|
||||
return userRows.length > 0 ? userRows[0] : null;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
@@ -26,20 +26,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
|
||||
// Verify habit belongs to user
|
||||
const [habit] = await db
|
||||
const habitRows = await db
|
||||
.select()
|
||||
.from(habits)
|
||||
.where(and(eq(habits.id, habitId), eq(habits.userId, user.id)));
|
||||
|
||||
if (!habit) {
|
||||
if (habitRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Habit not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = (await request.json()) as { note?: string };
|
||||
const { note } = body;
|
||||
|
||||
// Create log entry
|
||||
const [log] = await db
|
||||
const logRows = await db
|
||||
.insert(habitLogs)
|
||||
.values({
|
||||
habitId,
|
||||
@@ -47,6 +47,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (logRows.length === 0) {
|
||||
throw new Error('Failed to create log entry');
|
||||
}
|
||||
|
||||
const log = logRows[0];
|
||||
return NextResponse.json({ log });
|
||||
} catch (error) {
|
||||
console.error('Log habit error:', error);
|
||||
|
@@ -7,11 +7,11 @@ async function getUserFromToken() {
|
||||
const token = await getTokenCookie();
|
||||
if (!token) return null;
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.token, token));
|
||||
return user;
|
||||
const userRows = await db.select().from(users).where(eq(users.token, token));
|
||||
return userRows.length > 0 ? userRows[0] : null;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getUserFromToken();
|
||||
if (!user) {
|
||||
@@ -83,7 +83,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const body = (await request.json()) as {
|
||||
name: string;
|
||||
type: string;
|
||||
targetFrequency?: { value: number; period: 'day' | 'week' | 'month' };
|
||||
color?: string;
|
||||
icon?: string;
|
||||
};
|
||||
const { name, type, targetFrequency, color, icon } = body;
|
||||
|
||||
if (!name || !type) {
|
||||
@@ -95,18 +101,33 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const [newHabit] = await db
|
||||
// Validate type is one of the allowed enum values
|
||||
if (!['positive', 'neutral', 'negative'].includes(type)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Type must be one of: positive, neutral, negative',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const newHabitRows = await db
|
||||
.insert(habits)
|
||||
.values({
|
||||
userId: user.id,
|
||||
name,
|
||||
type,
|
||||
type: type as 'positive' | 'neutral' | 'negative',
|
||||
targetFrequency,
|
||||
color,
|
||||
icon,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (newHabitRows.length === 0) {
|
||||
throw new Error('Failed to create habit');
|
||||
}
|
||||
|
||||
const newHabit = newHabitRows[0];
|
||||
return NextResponse.json({ habit: newHabit });
|
||||
} catch (error) {
|
||||
console.error('Create habit error:', error);
|
||||
|
@@ -36,6 +36,12 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface AuthData {
|
||||
authenticated: boolean;
|
||||
token?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
interface Habit {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -47,6 +53,23 @@ interface Habit {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface HabitsResponse {
|
||||
habits: Habit[];
|
||||
}
|
||||
|
||||
interface LogResponse {
|
||||
log: {
|
||||
id: number;
|
||||
habitId: number;
|
||||
loggedAt: string;
|
||||
note?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface HabitResponse {
|
||||
habit: Habit;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -57,11 +80,11 @@ export default function Dashboard() {
|
||||
const [copiedToken, setCopiedToken] = useState(false);
|
||||
|
||||
// Check authentication
|
||||
const { data: authData, isLoading: authLoading } = useQuery({
|
||||
const { data: authData, isLoading: authLoading } = useQuery<AuthData>({
|
||||
queryKey: ['auth'],
|
||||
queryFn: async () => {
|
||||
queryFn: async (): Promise<AuthData> => {
|
||||
const res = await fetch('/api/auth');
|
||||
const data = await res.json();
|
||||
const data = (await res.json()) as AuthData;
|
||||
if (!data.authenticated) {
|
||||
router.push('/');
|
||||
}
|
||||
@@ -76,45 +99,45 @@ export default function Dashboard() {
|
||||
}, [authData]);
|
||||
|
||||
// Fetch habits
|
||||
const { data: habitsData, isLoading: habitsLoading } = useQuery({
|
||||
const { data: habitsData, isLoading: habitsLoading } = useQuery<HabitsResponse>({
|
||||
queryKey: ['habits'],
|
||||
queryFn: async () => {
|
||||
queryFn: async (): Promise<HabitsResponse> => {
|
||||
const res = await fetch('/api/habits');
|
||||
if (!res.ok) throw new Error('Failed to fetch habits');
|
||||
return res.json();
|
||||
return res.json() as Promise<HabitsResponse>;
|
||||
},
|
||||
enabled: !!authData?.authenticated,
|
||||
});
|
||||
|
||||
// Log habit mutation
|
||||
const logHabitMutation = useMutation({
|
||||
mutationFn: async (habitId: number) => {
|
||||
const res = await fetch(`/api/habits/${habitId}/log`, {
|
||||
const logHabitMutation = useMutation<LogResponse, Error, number>({
|
||||
mutationFn: async (habitId: number): Promise<LogResponse> => {
|
||||
const res = await fetch(`/api/habits/${String(habitId)}/log`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to log habit');
|
||||
return res.json();
|
||||
return res.json() as Promise<LogResponse>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['habits'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['habits'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Create habit mutation
|
||||
const createHabitMutation = useMutation({
|
||||
mutationFn: async (data: { name: string; type: string }) => {
|
||||
const createHabitMutation = useMutation<HabitResponse, Error, { name: string; type: string }>({
|
||||
mutationFn: async (data: { name: string; type: string }): Promise<HabitResponse> => {
|
||||
const res = await fetch('/api/habits', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create habit');
|
||||
return res.json();
|
||||
return res.json() as Promise<HabitResponse>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['habits'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['habits'] });
|
||||
setShowNewHabitDialog(false);
|
||||
setNewHabitName('');
|
||||
setNewHabitType('neutral');
|
||||
@@ -132,9 +155,11 @@ export default function Dashboard() {
|
||||
|
||||
const copyToken = () => {
|
||||
if (userToken) {
|
||||
navigator.clipboard.writeText(userToken);
|
||||
void navigator.clipboard.writeText(userToken);
|
||||
setCopiedToken(true);
|
||||
setTimeout(() => setCopiedToken(false), 2000);
|
||||
setTimeout(() => {
|
||||
setCopiedToken(false);
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,7 +224,7 @@ export default function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
const habits = habitsData?.habits || [];
|
||||
const habits = habitsData?.habits ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black">
|
||||
@@ -233,13 +258,20 @@ export default function Dashboard() {
|
||||
id="name"
|
||||
placeholder="e.g., Exercise, Read, Meditate..."
|
||||
value={newHabitName}
|
||||
onChange={(e) => setNewHabitName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setNewHabitName(e.target.value);
|
||||
}}
|
||||
className="border-zinc-800 bg-zinc-900"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="type">Habit Type</Label>
|
||||
<Select value={newHabitType} onValueChange={(value: any) => setNewHabitType(value)}>
|
||||
<Select
|
||||
value={newHabitType}
|
||||
onValueChange={(value: 'positive' | 'neutral' | 'negative') => {
|
||||
setNewHabitType(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="border-zinc-800 bg-zinc-900">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -267,7 +299,12 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowNewHabitDialog(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowNewHabitDialog(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
@@ -317,7 +354,9 @@ export default function Dashboard() {
|
||||
className={`transform cursor-pointer transition-all duration-200 hover:scale-[1.02] ${getHabitCardClass(
|
||||
habit.type,
|
||||
)} ${logHabitMutation.isPending ? 'opacity-75' : ''}`}
|
||||
onClick={() => logHabitMutation.mutate(habit.id)}
|
||||
onClick={() => {
|
||||
logHabitMutation.mutate(habit.id);
|
||||
}}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -384,7 +423,9 @@ export default function Dashboard() {
|
||||
<h3 className="mb-2 text-lg font-semibold">No habits yet</h3>
|
||||
<p className="mb-4 text-sm text-zinc-500">Start building better habits today</p>
|
||||
<Button
|
||||
onClick={() => setShowNewHabitDialog(true)}
|
||||
onClick={() => {
|
||||
setShowNewHabitDialog(true);
|
||||
}}
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
|
@@ -9,7 +9,14 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { LogIn, Sparkles, Shield, Zap, ArrowLeft } from 'lucide-react';
|
||||
import { LogIn, Shield, Zap, ArrowLeft, Activity, CalendarCheck } from 'lucide-react';
|
||||
|
||||
interface AuthResponse {
|
||||
success?: boolean;
|
||||
token?: string;
|
||||
userId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function Welcome() {
|
||||
const router = useRouter();
|
||||
@@ -18,14 +25,14 @@ export default function Welcome() {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const createAccountMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
mutationFn: async (): Promise<AuthResponse> => {
|
||||
const res = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'create' }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create account');
|
||||
return res.json();
|
||||
return res.json() as Promise<AuthResponse>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.push('/dashboard');
|
||||
@@ -36,17 +43,17 @@ export default function Welcome() {
|
||||
});
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (token: string) => {
|
||||
mutationFn: async (token: string): Promise<AuthResponse> => {
|
||||
const res = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'login', token }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || 'Failed to login');
|
||||
const data = (await res.json()) as AuthResponse;
|
||||
throw new Error(data.error ?? 'Failed to login');
|
||||
}
|
||||
return res.json();
|
||||
return res.json() as Promise<AuthResponse>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.push('/dashboard');
|
||||
@@ -63,6 +70,12 @@ export default function Welcome() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleTokenLogin();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md border-zinc-800 bg-zinc-950">
|
||||
<CardHeader className="space-y-2 text-center">
|
||||
@@ -93,7 +106,7 @@ export default function Welcome() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-emerald-950">
|
||||
<Sparkles className="h-4 w-4 text-emerald-500" />
|
||||
<Activity className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<p className="text-zinc-400">Track positive, neutral, or negative habits</p>
|
||||
</div>
|
||||
@@ -104,7 +117,9 @@ export default function Welcome() {
|
||||
{/* Actions */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
onClick={() => createAccountMutation.mutate()}
|
||||
onClick={() => {
|
||||
createAccountMutation.mutate();
|
||||
}}
|
||||
disabled={createAccountMutation.isPending}
|
||||
className="w-full bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
size="lg"
|
||||
@@ -113,7 +128,7 @@ export default function Welcome() {
|
||||
<>Creating your account...</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
<CalendarCheck className="mr-2 h-4 w-4" />
|
||||
Start Tracking Now
|
||||
</>
|
||||
)}
|
||||
@@ -130,7 +145,9 @@ export default function Welcome() {
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowTokenInput(true)}
|
||||
onClick={() => {
|
||||
setShowTokenInput(true);
|
||||
}}
|
||||
className="w-full border-zinc-800 hover:bg-zinc-900"
|
||||
size="lg"
|
||||
>
|
||||
@@ -161,8 +178,10 @@ export default function Welcome() {
|
||||
type="text"
|
||||
placeholder="e.g., happy-blue-cat-1234"
|
||||
value={tokenInput}
|
||||
onChange={(e) => setTokenInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleTokenLogin()}
|
||||
onChange={(e) => {
|
||||
setTokenInput(e.target.value);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="border-zinc-800 bg-zinc-900 placeholder:text-zinc-600"
|
||||
autoFocus
|
||||
/>
|
||||
|
@@ -1,11 +1,17 @@
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
import { env } from 'node:process';
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config({ path: ['.env.local', '.env'] });
|
||||
|
||||
const DATABASE_URL = process.env.POSTGRES_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error('POSTGRES_URL environment variable is required');
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
schema: './lib/db/schema.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: env.POSTGRES_URL!,
|
||||
url: DATABASE_URL,
|
||||
},
|
||||
out: './drizzle',
|
||||
});
|
||||
|
@@ -1,8 +1,14 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import * as schema from './schema';
|
||||
import 'dotenv/config';
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config({ path: ['.env.local', '.env'] });
|
||||
|
||||
export const db = drizzle(process.env.POSTGRES_URL!, { schema });
|
||||
const DATABASE_URL = process.env.POSTGRES_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error('POSTGRES_URL environment variable is required');
|
||||
}
|
||||
|
||||
export const db = drizzle(DATABASE_URL, { schema });
|
||||
|
||||
// Re-export schema types for convenience
|
||||
export * from './schema';
|
||||
|
@@ -28,9 +28,7 @@ export const habits = pgTable(
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
archivedAt: timestamp('archived_at'),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('habits_user_id_idx').on(table.userId),
|
||||
}),
|
||||
(table) => [index('habits_user_id_idx').on(table.userId)],
|
||||
);
|
||||
|
||||
export const habitLogs = pgTable(
|
||||
@@ -43,10 +41,10 @@ export const habitLogs = pgTable(
|
||||
loggedAt: timestamp('logged_at').defaultNow().notNull(),
|
||||
note: text('note'),
|
||||
},
|
||||
(table) => ({
|
||||
habitIdIdx: index('habit_logs_habit_id_idx').on(table.habitId),
|
||||
loggedAtIdx: index('habit_logs_logged_at_idx').on(table.loggedAt),
|
||||
}),
|
||||
(table) => [
|
||||
index('habit_logs_habit_id_idx').on(table.habitId),
|
||||
index('habit_logs_logged_at_idx').on(table.loggedAt),
|
||||
],
|
||||
);
|
||||
|
||||
// Relations
|
||||
|
Reference in New Issue
Block a user