initial webapp
This commit is contained in:
26
lib/auth/cookies.ts
Normal file
26
lib/auth/cookies.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const TOKEN_COOKIE_NAME = 'habit-tracker-token';
|
||||
const TOKEN_COOKIE_OPTIONS = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
maxAge: 60 * 60 * 24 * 365, // 1 year
|
||||
path: '/',
|
||||
};
|
||||
|
||||
export async function setTokenCookie(token: string) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(TOKEN_COOKIE_NAME, token, TOKEN_COOKIE_OPTIONS);
|
||||
}
|
||||
|
||||
export async function getTokenCookie(): Promise<string | undefined> {
|
||||
const cookieStore = await cookies();
|
||||
const cookie = cookieStore.get(TOKEN_COOKIE_NAME);
|
||||
return cookie?.value;
|
||||
}
|
||||
|
||||
export async function deleteTokenCookie() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete(TOKEN_COOKIE_NAME);
|
||||
}
|
167
lib/auth/tokens.ts
Normal file
167
lib/auth/tokens.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
|
||||
// Word lists for generating memorable tokens
|
||||
const adjectives = [
|
||||
'quick',
|
||||
'lazy',
|
||||
'happy',
|
||||
'brave',
|
||||
'bright',
|
||||
'calm',
|
||||
'clever',
|
||||
'eager',
|
||||
'gentle',
|
||||
'kind',
|
||||
'lively',
|
||||
'proud',
|
||||
'silly',
|
||||
'witty',
|
||||
'bold',
|
||||
'cool',
|
||||
'fair',
|
||||
'fine',
|
||||
'glad',
|
||||
'good',
|
||||
'neat',
|
||||
'nice',
|
||||
'rare',
|
||||
'safe',
|
||||
'warm',
|
||||
'wise',
|
||||
'fresh',
|
||||
'clean',
|
||||
'clear',
|
||||
'crisp',
|
||||
'sweet',
|
||||
'smooth',
|
||||
];
|
||||
|
||||
const colors = [
|
||||
'red',
|
||||
'blue',
|
||||
'green',
|
||||
'yellow',
|
||||
'purple',
|
||||
'orange',
|
||||
'pink',
|
||||
'black',
|
||||
'white',
|
||||
'gray',
|
||||
'brown',
|
||||
'cyan',
|
||||
'lime',
|
||||
'navy',
|
||||
'teal',
|
||||
'gold',
|
||||
'silver',
|
||||
'coral',
|
||||
'salmon',
|
||||
'indigo',
|
||||
'violet',
|
||||
'crimson',
|
||||
'azure',
|
||||
'jade',
|
||||
];
|
||||
|
||||
const animals = [
|
||||
'cat',
|
||||
'dog',
|
||||
'bird',
|
||||
'fish',
|
||||
'bear',
|
||||
'lion',
|
||||
'wolf',
|
||||
'fox',
|
||||
'deer',
|
||||
'owl',
|
||||
'hawk',
|
||||
'duck',
|
||||
'goat',
|
||||
'seal',
|
||||
'crab',
|
||||
'moth',
|
||||
'bee',
|
||||
'ant',
|
||||
'bat',
|
||||
'cow',
|
||||
'pig',
|
||||
'hen',
|
||||
'ram',
|
||||
'rat',
|
||||
'eel',
|
||||
'cod',
|
||||
'jay',
|
||||
'yak',
|
||||
'ox',
|
||||
'pug',
|
||||
'doe',
|
||||
'hog',
|
||||
];
|
||||
|
||||
const nouns = [
|
||||
'moon',
|
||||
'star',
|
||||
'cloud',
|
||||
'river',
|
||||
'mountain',
|
||||
'ocean',
|
||||
'forest',
|
||||
'desert',
|
||||
'island',
|
||||
'valley',
|
||||
'meadow',
|
||||
'garden',
|
||||
'bridge',
|
||||
'castle',
|
||||
'tower',
|
||||
'light',
|
||||
'shadow',
|
||||
'dream',
|
||||
'hope',
|
||||
'wish',
|
||||
'song',
|
||||
'dance',
|
||||
'smile',
|
||||
'laugh',
|
||||
'gift',
|
||||
'pearl',
|
||||
'jewel',
|
||||
'crown',
|
||||
'shield',
|
||||
'sword',
|
||||
'arrow',
|
||||
'bow',
|
||||
];
|
||||
|
||||
// Generate a 4-digit number suffix for uniqueness
|
||||
const generateNumber = customAlphabet('0123456789', 4);
|
||||
|
||||
function getRandomElement<T>(array: T[]): T {
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
}
|
||||
|
||||
export function generateMemorableToken(): string {
|
||||
const parts = [
|
||||
getRandomElement(adjectives),
|
||||
getRandomElement(colors),
|
||||
getRandomElement(animals),
|
||||
generateNumber(),
|
||||
];
|
||||
|
||||
return parts.join('-');
|
||||
}
|
||||
|
||||
export function generateShortToken(): string {
|
||||
const parts = [getRandomElement(colors), getRandomElement(nouns), generateNumber()];
|
||||
|
||||
return parts.join('-');
|
||||
}
|
||||
|
||||
// Validate token format
|
||||
export function isValidToken(token: string): boolean {
|
||||
// Check if token matches our format (word-word-word-4digits or word-word-4digits)
|
||||
const longFormat = /^[a-z]+-[a-z]+-[a-z]+-\d{4}$/;
|
||||
const shortFormat = /^[a-z]+-[a-z]+-\d{4}$/;
|
||||
|
||||
return longFormat.test(token) || shortFormat.test(token);
|
||||
}
|
8
lib/db/index.ts
Normal file
8
lib/db/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import * as schema from './schema';
|
||||
import 'dotenv/config';
|
||||
|
||||
export const db = drizzle(process.env.POSTGRES_URL!, { schema });
|
||||
|
||||
// Re-export schema types for convenience
|
||||
export * from './schema';
|
78
lib/db/schema.ts
Normal file
78
lib/db/schema.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { pgTable, serial, text, timestamp, integer, jsonb, boolean, index } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export const users = pgTable('users', {
|
||||
id: serial('id').primaryKey(),
|
||||
token: text('token').notNull().unique(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const habits = pgTable(
|
||||
'habits',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
name: text('name').notNull(),
|
||||
type: text('type', { enum: ['positive', 'neutral', 'negative'] })
|
||||
.notNull()
|
||||
.default('neutral'),
|
||||
targetFrequency: jsonb('target_frequency').$type<{
|
||||
value: number;
|
||||
period: 'day' | 'week' | 'month';
|
||||
}>(),
|
||||
color: text('color'),
|
||||
icon: text('icon'),
|
||||
isArchived: boolean('is_archived').default(false).notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
archivedAt: timestamp('archived_at'),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index('habits_user_id_idx').on(table.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const habitLogs = pgTable(
|
||||
'habit_logs',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
habitId: integer('habit_id')
|
||||
.references(() => habits.id)
|
||||
.notNull(),
|
||||
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),
|
||||
}),
|
||||
);
|
||||
|
||||
// Relations
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
habits: many(habits),
|
||||
}));
|
||||
|
||||
export const habitsRelations = relations(habits, ({ one, many }) => ({
|
||||
user: one(users, {
|
||||
fields: [habits.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
logs: many(habitLogs),
|
||||
}));
|
||||
|
||||
export const habitLogsRelations = relations(habitLogs, ({ one }) => ({
|
||||
habit: one(habits, {
|
||||
fields: [habitLogs.habitId],
|
||||
references: [habits.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// Types
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
export type Habit = typeof habits.$inferSelect;
|
||||
export type NewHabit = typeof habits.$inferInsert;
|
||||
export type HabitLog = typeof habitLogs.$inferSelect;
|
||||
export type NewHabitLog = typeof habitLogs.$inferInsert;
|
@@ -1,6 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
Reference in New Issue
Block a user