Path: blob/master/src/packages/util/db-schema/accounts.ts
1447 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { NOTES } from "./crm";6import { SCHEMA as schema } from "./index";7import { checkAccountName } from "./name-rules";8import { Table } from "./types";910import {11DEFAULT_FONT_SIZE,12DEFAULT_NEW_FILENAMES,13NEW_FILENAMES,14OTHER_SETTINGS_USERDEFINED_LLM,15} from "./defaults";1617import { DEFAULT_LOCALE } from "@cocalc/util/consts/locale";1819export const USER_SEARCH_LIMIT = 250;20export const ADMIN_SEARCH_LIMIT = 2500;2122export const USE_BALANCE_TOWARD_SUBSCRIPTIONS =23"use_balance_toward_subscriptions";24export const USE_BALANCE_TOWARD_SUBSCRIPTIONS_DEFAULT = true;2526// AutoBalance: Every parameter is in dollars.27export interface AutoBalance {28// deposit money when the balance goes below this29trigger: number;30// amount to automatically add31amount: number;32// max amount of money to add per day33max_day: number;34// max amount of money to add per week35max_week: number;36// max amount of money to add per month37max_month: number;38// period -- which of max_day, max_week, or max_month to actually enforce.39// we always enforce **exactly one of them**.40period: "day" | "week" | "month";41// switch to disable/enable this.42enabled: boolean;43// if credit was not added, last reason why (at most 1024 characters)44reason?: string;45// ms since epoch of last attempt46time?: number;47// how much has been added at the moment when we last updated.48status?: { day: number; week: number; month: number };49}5051// each of the parameters above must be a number in the52// given interval below.53// All fields should always be explicitly specified.54export const AUTOBALANCE_RANGES = {55trigger: [5, 250],56amount: [10, 250],57max_day: [5, 1000],58max_week: [5, 5000],59max_month: [5, 10000],60};6162export const AUTOBALANCE_DEFAULTS = {63trigger: 10,64amount: 20,65max_day: 200,66max_week: 1000,67max_month: 2500,68period: "week",69enabled: true,70} as AutoBalance;7172// throw error if not valid73export function ensureAutoBalanceValid(obj) {74if (obj == null) {75return;76}77if (typeof obj != "object") {78throw Error("must be an object");79}80for (const key in AUTOBALANCE_RANGES) {81if (obj[key] == null) {82throw Error(`${key} must be specified`);83}84}85for (const key in obj) {86if (key == "period") {87if (!["day", "week", "month"].includes(obj[key])) {88throw Error(`${key} must be 'day', 'week' or 'month'`);89}90continue;91}92if (key == "enabled") {93if (typeof obj[key] != "boolean") {94throw Error(`${key} must be boolean`);95}96continue;97}98if (key == "reason") {99if (typeof obj[key] != "string") {100throw Error(`${key} must be a string`);101}102if (obj[key].length > 1024) {103throw Error(`${key} must be at most 1024 characters`);104}105continue;106}107if (key == "time") {108if (typeof obj[key] != "number") {109throw Error(`${key} must be a number`);110}111continue;112}113if (key == "status") {114if (typeof obj[key] != "object") {115throw Error(`${key} must be an object`);116}117continue;118}119const range = AUTOBALANCE_RANGES[key];120if (range == null) {121throw Error(`invalid key '${key}'`);122}123const value = obj[key];124if (typeof value != "number") {125throw Error("every value must be a number");126}127if (value < range[0]) {128throw Error(`${key} must be at least ${range[0]}`);129}130if (value > range[1]) {131throw Error(`${key} must be at most ${range[1]}`);132}133}134}135136Table({137name: "accounts",138fields: {139account_id: {140type: "uuid",141desc: "The uuid that determines the user account",142render: { type: "account" },143title: "Account",144},145created: {146type: "timestamp",147desc: "When the account was created.",148},149created_by: {150type: "string",151pg_type: "inet",152desc: "IP address that created the account.",153},154creation_actions_done: {155type: "boolean",156desc: "Set to true after all creation actions (e.g., add to projects) associated to this account are succesfully completed.",157},158password_hash: {159type: "string",160pg_type: "VARCHAR(173)",161desc: "Hash of the password. This is 1000 iterations of sha512 with salt of length 32.",162},163deleted: {164type: "boolean",165desc: "True if the account has been deleted.",166},167name: {168type: "string",169pg_type: "VARCHAR(39)",170desc: "The username of this user. This is optional but globally unique across all accoutns *and* organizations. It can be between 1 and 39 characters from a-z A-Z 0-9 - and must not start with a dash.",171},172email_address: {173type: "string",174pg_type: "VARCHAR(254)", // see http://stackoverflow.com/questions/386294/what-is-the-maximum-length-of-a-valid-email-address175desc: "The email address of the user. This is optional, since users may instead be associated to passport logins.",176unique: true,177render: { type: "email_address" },178}, // only one record in database can have this email address (if given)179email_address_before_delete: {180type: "string",181desc: "The email address of the user before they deleted their account.",182},183email_address_verified: {184type: "map",185desc: 'Verified email addresses as { "[email protected]" : <timestamp>, ... }',186},187email_address_challenge: {188type: "map",189desc: 'Contains random token for verification of an address: {"email": "...", "token": <random>, "time" : <timestamp for timeout>}',190},191email_address_problem: {192type: "map",193desc: 'Describes a problem with a given email address. example: { "[email protected]" : { "type": "bounce", "time": "2018-...", "mesg": "554 5.7.1 <....>: Recipient address rejected: Access denied, user does not exist", "status": <status code>}}',194},195passports: {196type: "map",197desc: 'Map from string ("[strategy]-[id]") derived from passport name and id to the corresponding profile',198},199editor_settings: {200type: "map",201desc: "Description of configuration settings for the editor. See the user_query get defaults.",202},203other_settings: {204type: "map",205desc: "Miscellaneous overall configuration settings for CoCalc, e.g., confirm close on exit?",206},207first_name: {208type: "string",209pg_type: "VARCHAR(254)", // some limit (actually around 3000) is required for indexing210desc: "The first name of this user.",211render: { type: "text", maxLength: 254, editable: true },212},213last_name: {214type: "string",215pg_type: "VARCHAR(254)",216desc: "The last name of this user.",217render: { type: "text", maxLength: 254, editable: true },218},219banned: {220type: "boolean",221desc: "Whether or not this user is banned.",222render: {223type: "boolean",224editable: true,225},226},227terminal: {228type: "map",229desc: "Settings for the terminal, e.g., font_size, etc. (see get query)",230},231autosave: {232type: "integer",233desc: "File autosave interval in seconds",234},235evaluate_key: {236type: "string",237desc: "Key used to evaluate code in Sage worksheet.",238},239font_size: {240type: "integer",241desc: "Default font-size for the editor, jupyter, etc. (px)",242},243last_active: {244type: "timestamp",245desc: "When this user was last active.",246},247stripe_customer_id: {248type: "string",249desc: "The id of this customer in the stripe billing system.",250},251stripe_customer: {252type: "map",253desc: "Information about customer from the point of view of stripe (exactly what is returned by stripe.customers.retrieve) ALMOST DEPRECATED -- THIS IS ONLY USED FOR OLD LEGACY UPGRADES.",254},255coupon_history: {256type: "map",257desc: "Information about which coupons the customer has used and the number of times",258},259profile: {260type: "map",261desc: "Information related to displaying an avatar for this user's location and presence in a document or chatroom.",262},263groups: {264type: "array",265pg_type: "TEXT[]",266desc: "Array of groups that this user belongs to; usually empty. The only group right now is 'admin', which grants admin rights.",267},268ssh_keys: {269type: "map",270desc: "Map from ssh key fingerprints to ssh key objects.",271},272api_key: {273type: "string",274desc: "Optional API key that grants full API access to anything this account can access. Key is of the form 'sk_9QabcrqJFy7JIhvAGih5c6Nb', where the random part is 24 characters (base 62).",275unique: true,276},277sign_up_usage_intent: {278type: "string",279desc: "What user intended to use CoCalc for at sign up",280render: { type: "text" },281},282lti_id: {283type: "array",284pg_type: "TEXT[]",285desc: "LTI ISS and user ID",286},287lti_data: {288type: "map",289desc: "extra information related to LTI",290},291unlisted: {292type: "boolean",293desc: "If true then exclude user for full name searches (but not exact email address searches).",294render: {295type: "boolean",296editable: true,297},298},299tags: {300type: "array",301pg_type: "TEXT[]",302desc: "Tags expressing what this user is most interested in doing.",303render: { type: "string-tags", editable: true },304},305tours: {306type: "array",307pg_type: "TEXT[]",308desc: "Tours that user has seen, so once they are here they are hidden from the UI. The special tour 'all' means to disable all tour buttons.",309render: { type: "string-tags" },310},311notes: NOTES,312salesloft_id: {313type: "integer",314desc: "The id of corresponding person in salesloft, if they exist there.",315render: {316type: "number",317integer: true,318editable: true,319min: 1,320},321},322purchase_closing_day: {323type: "integer",324desc: "Day of the month when pay-as-you-go purchases are cutoff and charged for this user. It happens at midnight UTC on this day. This should be an integer between 1 and 28.",325render: {326type: "number",327editable: false, // Do NOT change this without going through the reset-closing-date api call...328min: 1,329max: 28,330},331},332min_balance: {333type: "number",334pg_type: "REAL",335desc: "The minimum allowed balance for this user. This is a quota we impose for safety, not something they set. Admins may change this in response to a support request. For most users this is not set at all hence 0, but for some special enterprise-style customers to whom we extend 'credit', it will be set.",336render: {337title: "Minimum Allowed Balance (USD)",338type: "number",339integer: false,340editable: true,341max: 0,342},343},344balance: {345type: "number",346pg_type: "REAL",347desc: "Last computed balance for this user. NOT a source of truth. Meant to ensure all frontend clients show the same thing. Probably also useful for db queries and maybe analytics.",348render: {349title: "Account Balance (USD)",350type: "number",351integer: false,352editable: false,353},354},355balance_alert: {356type: "boolean",357desc: "If true, the UI will very strongly encourage user to open their balance modal.",358render: {359type: "boolean",360editable: true,361},362},363auto_balance: {364type: "map",365desc: "Determines protocol for automatically adding money to account. This is relevant for pay as you go users. The interface AutoBalance describes the parameters. The user can in theory set this to anything, but ]",366},367stripe_checkout_session: {368type: "map",369desc: "Part of the current open stripe checkout session object, namely {id:?, url:?}, but none of the other info. When user is going to add credit to their account, we create a stripe checkout session and store it here until they complete checking out. This makes it possible to guide them back to the checkout session, in case anything goes wrong, and also avoids confusion with potentially multiple checkout sessions at once.",370},371stripe_usage_subscription: {372type: "string",373pg_type: "varchar(256)",374desc: "Id of this user's stripe metered usage subscription, if they have one.",375},376email_daily_statements: {377type: "boolean",378desc: "If true, try to send daily statements to user showing all of their purchases. If false or not set, then do not. NOTE: we always try to email monthly statements to users.",379render: {380type: "boolean",381editable: true,382},383},384owner_id: {385type: "uuid",386desc: "If one user (owner_id) creates an account for another user via the API, then this records who created the account. They may have special privileges at some point.",387render: { type: "account" },388title: "Owner",389},390unread_message_count: {391type: "integer",392desc: "Number of unread messages in the messages table for this user. This gets updated whenever the messages table for this user gets changed, making it easier to have UI etc when there are unread messages.",393render: {394type: "number",395editable: false,396min: 0,397},398},399last_message_summary: {400type: "timestamp",401desc: "The last time the system sent an email to this user with a summary about new messages (see messages.ts).",402},403},404rules: {405desc: "All user accounts.",406primary_key: "account_id",407// db_standby: "unsafe",408pg_indexes: [409"(lower(first_name) text_pattern_ops)",410"(lower(last_name) text_pattern_ops)",411"created_by",412"created",413"last_active DESC NULLS LAST",414"lti_id",415"unlisted",416"((passports IS NOT NULL))",417"((ssh_keys IS NOT NULL))", // used by ssh-gateway to speed up getting all users418],419crm_indexes: [420"(lower(first_name) text_pattern_ops)",421"(lower(last_name) text_pattern_ops)",422"(lower(email_address) text_pattern_ops)",423"created",424"last_active DESC NULLS LAST",425],426pg_unique_indexes: [427"api_key", // we use the map api_key --> account_id, so it better be unique428"LOWER(name)", // ensure user-assigned name is case sensitive globally unique429], // note that we actually require uniqueness across accounts and organizations430// and this index is just a step in that direction; full uniquness must be431// checked as an extra step.432user_query: {433get: {434throttle_changes: 500,435pg_where: [{ "account_id = $::UUID": "account_id" }],436fields: {437// Exactly what from the below is sync'd by default with the frontend app client is explicitly438// listed in frontend/account/table.ts439account_id: null,440email_address: null,441lti_id: null,442stripe_checkout_session: null,443email_address_verified: null,444email_address_problem: null,445editor_settings: {446/* NOTE: there is a editor_settings.jupyter = { kernel...} that isn't documented here. */447strip_trailing_whitespace: false,448show_trailing_whitespace: false,449line_wrapping: true,450line_numbers: true,451jupyter_line_numbers: false,452smart_indent: true,453electric_chars: true,454match_brackets: true,455auto_close_brackets: true,456code_folding: true,457match_xml_tags: true,458auto_close_xml_tags: true,459auto_close_latex: true,460spaces_instead_of_tabs: true,461multiple_cursors: true,462track_revisions: true,463extra_button_bar: true,464build_on_save: true,465first_line_number: 1,466indent_unit: 4,467tab_size: 4,468bindings: "standard",469theme: "default",470undo_depth: 300,471jupyter_classic: false,472jupyter_window: false,473disable_jupyter_windowing: false,474show_exec_warning: true,475physical_keyboard: "default",476keyboard_variant: "",477ask_jupyter_kernel: true,478show_my_other_cursors: false,479disable_jupyter_virtualization: false,480},481other_settings: {482katex: true,483confirm_close: false,484mask_files: true,485page_size: 500,486standby_timeout_m: 5,487default_file_sort: "name",488[NEW_FILENAMES]: DEFAULT_NEW_FILENAMES,489show_global_info2: null,490first_steps: true,491newsletter: false,492time_ago_absolute: false,493// if true, do not show warning when using non-member projects494no_free_warnings: false,495allow_mentions: true,496dark_mode: false,497dark_mode_brightness: 100,498dark_mode_contrast: 90,499dark_mode_sepia: 0,500dark_mode_grayscale: 0,501news_read_until: 0,502hide_project_popovers: false,503hide_file_popovers: false,504hide_button_tooltips: false,505[OTHER_SETTINGS_USERDEFINED_LLM]: "[]",506i18n: DEFAULT_LOCALE,507no_email_new_messages: false,508[USE_BALANCE_TOWARD_SUBSCRIPTIONS]:509USE_BALANCE_TOWARD_SUBSCRIPTIONS_DEFAULT,510hide_navbar_balance: false,511},512name: null,513first_name: "",514last_name: "",515terminal: {516font_size: DEFAULT_FONT_SIZE,517color_scheme: "default",518font: "monospace",519},520autosave: 45,521evaluate_key: "Shift-Enter",522font_size: DEFAULT_FONT_SIZE,523passports: {},524groups: [],525last_active: null,526stripe_customer: null,527coupon_history: null,528profile: {529image: undefined,530color: "rgb(170,170,170)",531},532ssh_keys: {},533created: null,534unlisted: false,535tags: null,536tours: null,537min_balance: null,538balance: null,539balance_alert: null,540auto_balance: null,541purchase_closing_day: null,542stripe_usage_subscription: null,543email_daily_statements: null,544unread_message_count: null,545},546},547set: {548fields: {549account_id: "account_id",550name: true,551editor_settings: true,552other_settings: true,553first_name: true,554last_name: true,555terminal: true,556autosave: true,557evaluate_key: true,558font_size: true,559profile: true,560ssh_keys: true,561sign_up_usage_intent: true,562unlisted: true,563tags: true,564tours: true,565email_daily_statements: true,566// obviously min_balance can't be set!567auto_balance: true,568},569async check_hook(db, obj, account_id, _project_id, cb) {570if (obj["name"] != null) {571// NOTE: there is no way to unset/remove a username after one is set...572try {573checkAccountName(obj["name"]);574} catch (err) {575cb(err.toString());576return;577}578const id = await db.nameToAccountOrOrganization(obj["name"]);579if (id != null && id != account_id) {580cb(581`name "${obj["name"]}" is already taken by another organization or account`,582);583return;584}585}586// Hook to truncate some text fields to at most 254 characters, to avoid587// further trouble down the line.588for (const field of ["first_name", "last_name", "email_address"]) {589if (obj[field] != null) {590obj[field] = obj[field].slice(0, 254);591if (field != "email_address" && !obj[field]) {592// name fields can't be empty593cb(`${field} must be nonempty`);594return;595}596}597}598599// Make sure auto_balance is valid.600if (obj["auto_balance"] != null) {601try {602ensureAutoBalanceValid(obj["auto_balance"]);603} catch (err) {604cb(`${err}`);605return;606}607}608cb();609},610},611},612},613});614615export const EDITOR_BINDINGS = {616standard: "Standard",617sublime: "Sublime",618vim: "Vim",619emacs: "Emacs",620};621622export const EDITOR_COLOR_SCHEMES: { [name: string]: string } = {623default: "Default",624"3024-day": "3024 day",625"3024-night": "3024 night",626abcdef: "abcdef",627abbott: "Abbott",628"ayu-dark": "Ayu dark",629"ayu-mirage": "Ayu mirage",630//'ambiance-mobile' : 'Ambiance mobile' # doesn't highlight python, confusing631ambiance: "Ambiance",632"base16-dark": "Base 16 dark",633"base16-light": "Base 16 light",634bespin: "Bespin",635blackboard: "Blackboard",636cobalt: "Cobalt",637colorforth: "Colorforth",638darcula: "Darcula",639dracula: "Dracula",640"duotone-dark": "Duotone Dark",641"duotone-light": "Duotone Light",642eclipse: "Eclipse",643elegant: "Elegant",644"erlang-dark": "Erlang dark",645"gruvbox-dark": "Gruvbox-Dark",646hopscotch: "Hopscotch",647icecoder: "Icecoder",648idea: "Idea", // this messes with the global hinter CSS!649isotope: "Isotope",650juejin: "Juejin",651"lesser-dark": "Lesser dark",652liquibyte: "Liquibyte",653lucario: "Lucario",654material: "Material",655"material-darker": "Material darker",656"material-ocean": "Material ocean",657"material-palenight": "Material palenight",658mbo: "mbo",659"mdn-like": "MDN like",660midnight: "Midnight",661monokai: "Monokai",662neat: "Neat",663neo: "Neo",664night: "Night",665"oceanic-next": "Oceanic next",666"panda-syntax": "Panda syntax",667"paraiso-dark": "Paraiso dark",668"paraiso-light": "Paraiso light",669"pastel-on-dark": "Pastel on dark",670railscasts: "Railscasts",671rubyblue: "Rubyblue",672seti: "Seti",673shadowfox: "Shadowfox",674"solarized dark": "Solarized dark",675"solarized light": "Solarized light",676ssms: "ssms",677"the-matrix": "The Matrix",678"tomorrow-night-bright": "Tomorrow Night - Bright",679"tomorrow-night-eighties": "Tomorrow Night - Eighties",680ttcn: "ttcn",681twilight: "Twilight",682"vibrant-ink": "Vibrant ink",683"xq-dark": "Xq dark",684"xq-light": "Xq light",685yeti: "Yeti",686yonce: "Yonce",687zenburn: "Zenburn",688};689690Table({691name: "crm_accounts",692rules: {693virtual: "accounts",694primary_key: "account_id",695user_query: {696get: {697pg_where: [],698admin: true, // only admins can do get queries on this table699fields: {700...schema.accounts.user_query?.get?.fields,701banned: null,702groups: null,703notes: null,704salesloft_id: null,705sign_up_usage_intent: null,706owner_id: null,707deleted: null,708},709},710set: {711admin: true, // only admins can do get queries on this table712fields: {713account_id: true,714name: true,715first_name: true,716last_name: true,717autosave: true,718font_size: true,719banned: true,720unlisted: true,721notes: true,722tags: true,723salesloft_id: true,724purchase_closing_day: true,725min_balance: true, // admins can set this726},727},728},729},730fields: schema.accounts.fields,731});732733Table({734name: "crm_agents",735rules: {736virtual: "accounts",737primary_key: "account_id",738user_query: {739get: {740// There where condition restricts to only admin accounts for now.741// TODO: Later this will change to 'crm'=any(groups) or something like that.742pg_where: ["'admin'=any(groups)"],743admin: true, // only admins can do get queries on this table744fields: schema.accounts.user_query?.get?.fields ?? {},745},746},747},748fields: schema.accounts.fields,749});750751interface Tag {752label: string;753tag: string;754language?: string; // language of jupyter kernel755icon?: any; // I'm not going to import the IconName type from @cocalc/frontend756welcome?: string; // a simple "welcome" of this type757jupyterExtra?: string;758torun?: string; // how to run this in a terminal (e.g., for a .py file).759color?: string;760description?: string;761}762763// They were used up until 2024-01-05764export const TAGS_FEATURES: Tag[] = [765{ label: "Jupyter", tag: "ipynb", color: "magenta" },766{767label: "Python",768tag: "py",769language: "python",770welcome: 'print("Welcome to CoCalc from Python!")',771torun: "# Click Terminal, then type 'python3 welcome.py'",772color: "red",773},774{775label: "AI / GPUs",776tag: "gpu",777color: "volcano",778icon: "gpu",779},780{781label: "R Stats",782tag: "R",783language: "r",784welcome: 'print("Welcome to CoCalc from R!")',785torun: "# Click Terminal, then type 'Rscript welcome.R'",786color: "orange",787},788{789label: "SageMath",790tag: "sage",791language: "sagemath",792welcome: "print('Welcome to CoCalc from Sage!', factor(2024))",793torun: "# Click Terminal, then type 'sage welcome.sage'",794color: "gold",795},796{797label: "Octave",798icon: "octave",799tag: "m",800language: "octave",801welcome: `disp("Welcome to CoCalc from Octave!")`,802torun: "% Click Terminal, then type 'octave --no-window-system welcome.m'",803color: "geekblue",804},805{806label: "Linux",807icon: "linux",808tag: "term",809language: "bash",810welcome: "echo 'Welcome to CoCalc from Linux/BASH!'",811color: "green",812},813{814label: "LaTeX",815tag: "tex",816welcome: `\\documentclass{article}817\\title{Welcome to CoCalc from \\LaTeX{}!}818\\begin{document}819\\maketitle820\\end{document}`,821color: "cyan",822},823{824label: "C/C++",825tag: "c",826language: "C++17",827icon: "cube",828welcome: `829#include <stdio.h>830int main() {831printf("Welcome to CoCalc from C!\\n");832return 0;833}`,834jupyterExtra: "\nmain();\n",835torun: "/* Click Terminal, then type 'gcc welcome.c && ./a.out' */",836color: "blue",837},838{839label: "Julia",840language: "julia",841icon: "julia",842tag: "jl",843welcome: 'println("Welcome to CoCalc from Julia!")',844torun: "# Click Terminal, then type 'julia welcome.jl' */",845color: "geekblue",846},847{848label: "Markdown",849tag: "md",850welcome:851"# Welcome to CoCalc from Markdown!\n\nYou can directly edit the rendered markdown -- try it!\n\nAnd run code:\n\n```py\n2+3\n```\n",852color: "purple",853},854// {855// label: "Whiteboard",856// tag: "board",857// welcome: `{"data":{"color":"#252937"},"h":96,"id":"1244fb1f","page":"b7cda7e9","str":"# Welcome to CoCalc from a Whiteboard!\\n\\n","type":"text","w":779,"x":-305,"y":-291,"z":1}858// {"data":{"pos":0},"id":"b7cda7e9","type":"page","z":0}`,859// },860{ label: "Teaching", tag: "course", color: "green" },861];862863export const TAG_TO_FEATURE: { [key: string]: Readonly<Tag> } = {};864for (const t of TAGS_FEATURES) {865TAG_TO_FEATURE[t.tag] = t;866}867868const professional = "professional";869870// Tags specific to user roles or if they want to be contacted871export const TAGS_USERS: Readonly<Tag[]> = [872{873label: "Personal",874tag: "personal",875icon: "user",876description: "You are interesting in using CoCalc for personal use.",877},878{879label: "Professional",880tag: professional,881icon: "coffee",882description: "You are using CoCalc as an employee or freelancer.",883},884{885label: "Instructor",886tag: "instructor",887icon: "graduation-cap",888description: "You are teaching a course.",889},890{891label: "Student",892tag: "student",893icon: "smile",894description: "You are a student in a course.",895},896] as const;897898export const TAGS = TAGS_USERS;899900export const TAGS_MAP: { [key: string]: Readonly<Tag> } = {};901for (const x of TAGS) {902TAGS_MAP[x.tag] = x;903}904905export const CONTACT_TAG = "contact";906export const CONTACT_THESE_TAGS = [professional];907908export interface UserSearchResult {909account_id: string;910first_name?: string;911last_name?: string;912name?: string; // "vanity" username913last_active?: number; // ms since epoch -- when account was last active914created?: number; // ms since epoch -- when account created915banned?: boolean; // true if this user has been banned (only set for admin searches, obviously)916email_address_verified?: boolean; // true if their email has been verified (a sign they are more trustworthy).917// For security reasons, the email_address *only* occurs in search queries that918// are by email_address (or for admins); we must not reveal email addresses919// of users queried by substring searches, obviously.920email_address?: string;921}922923export const ACCOUNT_ID_COOKIE_NAME = "account_id";924925926