File size: 4,897 Bytes
a0eafe5
9a951d7
 
8a24f2c
 
 
 
 
 
 
 
 
ec9da6a
 
 
 
 
8a24f2c
 
 
 
 
 
aa95439
 
 
b4d141a
 
 
 
 
 
 
 
ec9da6a
9ca537f
 
 
 
 
 
90c9777
9a951d7
 
90c9777
86063f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0eafe5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fe607c0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9a951d7
 
 
90c9777
9a951d7
 
8a24f2c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
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[];
}