import { Entity, PrimaryGeneratedColumn, Column, Index, OneToOne, OneToMany } from 'typeorm'; import { UserProfile } from './entities/user-profile.entity'; import { UserGoal } from './entities/user-goal.entity'; @Entity() export class User { @PrimaryGeneratedColumn('uuid') id!: string; @Column({ unique: true }) email!: string; @Column({ type: 'varchar', nullable: true }) password: string | null; @Column({ type:'varchar', nullable: true, unique: true }) // <-- Google ID field googleId: string | null; @Column() name!: string; @Column({ default: false }) isVerified!: boolean; @Column({ type: 'text', nullable: true }) refreshTokenHash: string | null; @Column({ type: 'varchar' , nullable: true }) otpHash: string | null; // store hashed OTP @Column({ type: 'timestamptz', nullable: true }) otpExpiry: Date | null; @Column({ type: 'timestamp', nullable: true }) refreshTokenIssuedAt: Date | null; @Column({ type: 'text', nullable: true }) resetTokenHash: string | null; @Column({ type: 'timestamp', nullable: true }) resetTokenExpiry: Date | null; @Column({ default: false }) isLoggedin!: boolean; /** * Access role. Added because the condition AdminKeyGuard's docblock set — * "if an admin dashboard is built later, replace this with a proper role * guard" — has now been met by the campaign admin UI. * * There is deliberately no self-serve path to 'admin': the column is set by * hand in the database. AdminKeyGuard stays in place on the existing scraper * maintenance routes; migrating those is unrelated risk. */ @Column({ type: 'varchar', length: 16, default: 'candidate' }) role!: 'candidate' | 'admin'; /** * Which campaign brought this user in — null for organic signups. * * This is the question the whole apply flow exists to answer: did posting a * role on LinkedIn actually acquire users, or did it just fill one seat? A * campaign that fills its role and adds nobody to the platform is a different * outcome from one that does both, and only this column can tell them apart. */ @Column({ type: 'uuid', nullable: true }) sourceCampaignId!: string | null; /** * Whether this person is currently open to being put forward for roles. * * `suspended` is set automatically when someone is submitted to companies * repeatedly and never turns up to the interview - see * CampaignsService.reviewAvailability. It is deliberately NOT a rejection or * a ban: it stops us spending a client's goodwill on a candidate who is not * actually available, and it lifts the moment they say otherwise. * * Only the candidate can lift it, by signing in and setting it back. An * operator cannot flip it for them: the whole point is to find out whether * the person is still looking, and only they can answer that. */ @Column({ type: 'varchar', length: 24, default: 'looking' }) availability!: 'looking' | 'suspended'; @Column({ type: 'timestamptz', nullable: true }) availabilityUpdatedAt!: Date | null; /** Shown to the candidate, so a suspension is never unexplained. */ @Column({ type: 'text', nullable: true }) suspensionReason!: string | null; /** * Last time the user did something deliberate — tracked an application, * updated one, or ran a custom search. Deliberately NOT bumped by feed reads: * a page load is not intent, and counting it would make everyone look active * forever. * * Stored as a timestamp rather than an `isActive` flag on purpose. Nothing has * to write "inactive" — no request arrives when a user stops caring — so a flag * would need a nightly sweep to stay honest and would drift whenever that * sweep failed. A timestamp goes stale on its own, and the activity window * becomes a number you can change without a migration or a backfill. */ @Index() @Column({ type: 'timestamptz', nullable: true }) lastActiveAt: Date | null; /** * When this account was created. * * Added late, which is why it is nullable rather than a @CreateDateColumn. * That decorator would have stamped every existing row with the deploy time * under `synchronize: true`, so the whole user base would have appeared to * sign up on the same afternoon - a wrong answer presented with total * confidence. NULL says "before we started recording this", which is true, * and the admin list sorts those last and labels them. * * scripts/backfill-user-created-at.mjs recovers a real date for most of them * from the earliest thing they did. */ @Index() @Column({ type: 'timestamptz', nullable: true }) createdAt: Date | null; // Relations @OneToOne(() => UserProfile, profile => profile.user, { cascade: true }) profile: UserProfile; @OneToMany(() => UserGoal, goal => goal.user, { cascade: true }) goals: UserGoal[]; }