diff --git a/README.md b/README.md index ef32328..e808030 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ npm run dev - **Timestamp Tracking:** Automatic timestamps for observations - **Markdown Support:** Format notes with headings, lists, code blocks - **Template System:** Use predefined interview question templates +- **Question Sets:** Choose a focused built-in set or [import custom questions and answer keys](docs/QUESTION_SETS.md) - **Collaborative Notes:** Multiple interviewers can add notes simultaneously - **Export Options:** Send to Slack, email, or download as PDF/JSON diff --git a/api/admins.js b/api/admins.js new file mode 100644 index 0000000..dcbc6d9 --- /dev/null +++ b/api/admins.js @@ -0,0 +1,113 @@ +'use strict'; + +const admin = require('firebase-admin'); +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); + +if (!admin.apps.length && process.env.FIREBASE_PROJECT_ID) { + admin.initializeApp({ + credential: admin.credential.cert({ + projectId: process.env.FIREBASE_PROJECT_ID, + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n') + }), + databaseURL: process.env.FIREBASE_DATABASE_URL + }); +} + +function emailKey(email) { + return Buffer.from(email.trim().toLowerCase()).toString('base64url'); +} + +function requireAdmin(req) { + const header = req.headers.authorization || ''; + if (!header.startsWith('Bearer ') || !process.env.JWT_SECRET) { + throw Object.assign(new Error('Authentication required'), { status: 401 }); + } + const decoded = jwt.verify(header.slice(7), process.env.JWT_SECRET); + if (decoded.isAdmin !== true) { + throw Object.assign(new Error('Admin access required'), { status: 403 }); + } + return decoded; +} + +module.exports = async function handler(req, res) { + res.setHeader('Cache-Control', 'private, no-store'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + + if (req.method === 'OPTIONS') return res.status(200).end(); + if (!['GET', 'POST'].includes(req.method)) { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const currentAdmin = requireAdmin(req); + if (!admin.apps.length) { + return res.status(503).json({ error: 'Admin storage is not configured' }); + } + const adminsRef = admin.database().ref('adminAccounts'); + const ownerEmail = String(process.env.ADMIN_EMAIL || '').trim().toLowerCase(); + + if (req.method === 'GET') { + const snapshot = await adminsRef.once('value'); + const additionalAdmins = Object.values(snapshot.val() || {}).map(account => ({ + email: account.email, + createdAt: account.createdAt, + createdBy: account.createdBy, + disabled: account.disabled === true, + owner: false + })); + const owner = ownerEmail + ? [{ email: ownerEmail, owner: true }] + : []; + return res.status(200).json({ admins: [...owner, ...additionalAdmins] }); + } + + if (!ownerEmail || String(currentAdmin.email || '').trim().toLowerCase() !== ownerEmail) { + return res.status(403).json({ error: 'Only the configured owner can create admins' }); + } + + const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body; + const email = String(body?.email || '').trim().toLowerCase(); + const password = String(body?.password || ''); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return res.status(400).json({ error: 'Enter a valid email address' }); + } + if (password.length < 8) { + return res.status(400).json({ error: 'Password must be at least 8 characters' }); + } + if (email === ownerEmail) { + return res.status(409).json({ error: 'That admin already exists' }); + } + + const accountRef = adminsRef.child(emailKey(email)); + const account = { + email, + passwordHash: await bcrypt.hash(password, 12), + createdAt: Date.now(), + createdBy: currentAdmin.email, + disabled: false + }; + const result = await accountRef.transaction(currentValue => { + if (currentValue !== null) return; + return account; + }); + if (!result.committed) { + return res.status(409).json({ error: 'That admin already exists' }); + } + + return res.status(201).json({ success: true, admin: { email, owner: false } }); + } catch (error) { + if (error instanceof SyntaxError) { + return res.status(400).json({ error: 'Invalid JSON body' }); + } + const authError = ['JsonWebTokenError', 'TokenExpiredError', 'NotBeforeError'] + .includes(error.name); + const status = error.status || (authError ? 401 : 500); + if (status === 500) console.error('Admin management error:', error); + return res.status(status).json({ + error: status === 500 ? 'Admin management failed' : error.message + }); + } +}; diff --git a/api/auth/login.js b/api/auth/login.js index e4e41a4..b400308 100644 --- a/api/auth/login.js +++ b/api/auth/login.js @@ -5,6 +5,22 @@ const bcrypt = require('bcryptjs'); const jwt = require('jsonwebtoken'); +const admin = require('firebase-admin'); + +if (!admin.apps.length && process.env.FIREBASE_PROJECT_ID) { + admin.initializeApp({ + credential: admin.credential.cert({ + projectId: process.env.FIREBASE_PROJECT_ID, + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n') + }), + databaseURL: process.env.FIREBASE_DATABASE_URL + }); +} + +function emailKey(email) { + return Buffer.from(email.trim().toLowerCase()).toString('base64url'); +} // Admin credentials (use environment variables in production) const ADMIN_EMAIL = process.env.ADMIN_EMAIL; @@ -41,11 +57,6 @@ module.exports = async (req, res) => { } } - // Debug logging (remove in production) - console.log('Request body type:', typeof req.body); - console.log('Request body:', req.body); - console.log('Parsed body:', body); - const { email, password } = body || {}; if (!email || !password) { @@ -61,13 +72,25 @@ module.exports = async (req, res) => { } try { - // Check email - if (email !== ADMIN_EMAIL) { - return res.status(401).json({ error: 'Invalid credentials' }); + const normalizedEmail = String(email).trim().toLowerCase(); + const normalizedOwnerEmail = ADMIN_EMAIL + ? String(ADMIN_EMAIL).trim().toLowerCase() + : ''; + const ownerLogin = Boolean(normalizedOwnerEmail) && + normalizedEmail === normalizedOwnerEmail; + let passwordHash = ownerLogin ? ADMIN_PASSWORD_HASH : null; + + if (!ownerLogin && admin.apps.length) { + const snapshot = await admin.database() + .ref(`adminAccounts/${emailKey(normalizedEmail)}`) + .once('value'); + const account = snapshot.val(); + if (account && account.disabled !== true) passwordHash = account.passwordHash; } - // Verify password - const validPassword = await bcrypt.compare(password, ADMIN_PASSWORD_HASH); + const validPassword = passwordHash + ? await bcrypt.compare(password, passwordHash) + : false; if (!validPassword) { return res.status(401).json({ error: 'Invalid credentials' }); } @@ -75,7 +98,7 @@ module.exports = async (req, res) => { // Generate token const token = jwt.sign( { - email: email, + email: normalizedEmail, isAdmin: true, userId: 'admin-' + Date.now() }, @@ -86,10 +109,10 @@ module.exports = async (req, res) => { res.status(200).json({ success: true, token: token, - user: { email: email, isAdmin: true } + user: { email: normalizedEmail, isAdmin: true } }); } catch (error) { console.error('Login error:', error); res.status(500).json({ error: 'Login failed' }); } -}; \ No newline at end of file +}; diff --git a/api/interview/templates-data.js b/api/interview/templates-data.js new file mode 100644 index 0000000..ffa2f8a --- /dev/null +++ b/api/interview/templates-data.js @@ -0,0 +1,933 @@ +'use strict'; + +module.exports = { + 'csharp-warmup': { + title: 'C# Warm-up — Discounted Premiums', + language: 'csharp', + content: `// 5-minute C# warm-up +// +// We are starting a small insurance application. +// +// A Policy stores an array because its premium can change each month. +// Complete CalculateDiscountedPremiumTotal: +// - Ignore monthly premiums that are zero or negative. +// - Give each valid monthly premium a 10% discount. +// - Return the total after the discounts. +// +// Example: +// Monthly premiums: { 100.00, 120.00, 0.00, -20.00 } +// Discounted values: { 90.00, 108.00 } +// Expected total: 198.00 + +using System; + +public class Policy +{ + public double[] MonthlyPremiums { get; set; } + + public Policy(double[] monthlyPremiums) + { + MonthlyPremiums = monthlyPremiums; + } + + public double CalculateDiscountedPremiumTotal() + { + double total = 0; + + // Write your loop and filter here. + + return total; + } +} + +public class Program +{ + public static void Main() + { + var policy = new Policy( + new double[] { 100.00, 120.00, 0.00, -20.00 }); + + Console.WriteLine( + policy.CalculateDiscountedPremiumTotal()); // Expected: 198.00 + } +}`, + answerKey: `PLAIN-ENGLISH ANSWER +Loop through the policy's MonthlyPremiums. Skip zero and negative values. For +each valid premium, keep 90% of it by multiplying by 0.90, then add that +discounted value to the total. + +ONE GOOD SOLUTION +public double CalculateDiscountedPremiumTotal() +{ + double total = 0; + + foreach (double premium in MonthlyPremiums) + { + if (premium > 0) + { + total += premium * 0.90; + } + } + + return total; +} + +HOW TO GRADE (0–3) +3 — Loops through the array, includes only positive premiums, applies the 10% + discount, and returns 198.00. +2 — Correct loop and filter but misses or slightly miscalculates the discount, + or has one small syntax mistake. +1 — Can explain the filter or discount but cannot combine them in the loop. +0 — Does not iterate through the monthly premiums or cannot identify the rules. + +ALSO CORRECT WITH LINQ +return MonthlyPremiums + .Where(premium => premium > 0) + .Sum(premium => premium * 0.90); + +Do not require LINQ, rounding, null handling, or exactly 12 array entries. The +point is to warm up with an object, an array, a filter, and simple arithmetic +before expanding the insurance model in the next question.` + }, + + 'csharp-rest-api': { + title: 'C# Public REST API Test', + language: 'csharp', + content: `// 5-minute REST API question +// +// We are using JSONPlaceholder, a free API made for testing. +// +// Documentation: +// https://jsonplaceholder.typicode.com/ +// +// Request: +// GET https://jsonplaceholder.typicode.com/todos/1 +// +// Expected response shape: +// { +// "userId": 1, +// "id": 1, +// "title": "delectus aut autem", +// "completed": false +// } +// +// Task: Complete GetTodoAsync using exactly these steps: +// 1. Send a GET request to "todos/1". +// 2. Throw an error if the response is not successful. +// 3. Read and return the response body as text. +// +// Before coding, open this URL in a browser to confirm the API is available: +// https://jsonplaceholder.typicode.com/todos/1 +// +// The interview editor's hosted C# runner might not allow outbound HTTP. +// Grade the method itself and ask the candidate to explain each line. + +using System; +using System.Net.Http; +using System.Threading.Tasks; + +public class TodoClient +{ + private readonly HttpClient _client; + + public TodoClient(HttpClient client) + { + _client = client; + } + + public async Task GetTodoAsync() + { + // Write 3 lines here. + return ""; + } +} + +public class Program +{ + public static async Task Main() + { + using var httpClient = new HttpClient + { + BaseAddress = new Uri("https://jsonplaceholder.typicode.com/") + }; + + var todoClient = new TodoClient(httpClient); + string json = await todoClient.GetTodoAsync(); + Console.WriteLine(json); + } +}`, + answerKey: `PLAIN-ENGLISH ANSWER +Use the provided HttpClient to send a GET request. Check that the server +returned success. Then read and return the response text. + +ONE GOOD SOLUTION +var response = await _client.GetAsync("todos/1"); +response.EnsureSuccessStatusCode(); +return await response.Content.ReadAsStringAsync(); + +WHAT EACH LINE DOES +1. GetAsync sends an HTTP GET and waits for the real server response. +2. EnsureSuccessStatusCode throws if the server returns 4xx or 5xx. +3. ReadAsStringAsync reads the JSON response body into a string. + +EXPECTED RESULT +Opening the endpoint in a browser should show JSON containing: +- "id": 1 +- "title": "delectus aut autem" +- "completed": false + +HOW TO GRADE (0–3) +3 — Writes all three lines correctly and can explain request, status, and body. +2 — Gets and returns the response but misses the status check or needs a hint. +1 — Knows an HTTP GET is needed but cannot form the method. +0 — Creates unrelated code or cannot explain request versus response. + +BONUS DISCUSSION (do not require) +- API keys belong in configuration, not source code. +- Production code should accept a CancellationToken. +- JSON would normally be deserialized into a C# Todo model. +- HttpClient should usually be injected rather than recreated per request.` + }, + + 'aspnet-mvc': { + title: 'ASP.NET MVC — Form Validation', + language: 'csharp', + content: `// 5-minute ASP.NET MVC question +// +// The Razor page below submits a Policy Number to this controller. +// A browser's "required" check can be bypassed, so the controller must +// also reject a blank PolicyNumber. +// +// Complete the two TODOs: +// 1. Add a validation attribute to PolicyNumber so it is required. +// 2. In the controller, show the form again when validation failed. + +// Create.cshtml (frontend) +// ----------------------- +// @model PolicyViewModel +// +//
+// +// +// +// +//
+ +using System; +using System.ComponentModel.DataAnnotations; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; + +public class PolicyViewModel +{ + // TODO 1: Make this required with the message: + // "Policy number is required." + public string PolicyNumber { get; set; } = ""; +} + +[HttpPost] +[ValidateAntiForgeryToken] +public async Task Create(PolicyViewModel model) +{ + // TODO 2: If ModelState is invalid, return View(model). + + await _policyService.CreateAsync(model); + return RedirectToAction("Index"); +}`, + answerKey: `PLAIN-ENGLISH ANSWER +The HTML "required" attribute helps the user, but it is not security: a request +can skip the browser. In MVC, a normal required-field rule belongs on the view +model as a [Required] data annotation. Model binding runs that validation on the +server and adds any failure to ModelState. The controller returns the same view +when ModelState is invalid, and +displays the field's error message. + +ONE GOOD SOLUTION +[Required(ErrorMessage = "Policy number is required.")] +public string PolicyNumber { get; set; } = ""; + +if (!ModelState.IsValid) +{ + return View(model); +} + +await _policyService.CreateAsync(model); +return RedirectToAction("Index"); + +HOW TO GRADE (0-3) +3 — Adds [Required], returns View(model) when ModelState is invalid, and saves + only valid input. +2 — Uses the correct validation pattern with a small syntax mistake, missing + custom message, or returns View() without preserving the model. +1 — Understands that the server must validate, but cannot implement both TODOs. +0 — Relies only on HTML "required" or still saves a blank PolicyNumber. + +WHAT TO LISTEN FOR +- The browser check improves usability; server validation protects the app. +- Returning View(model) preserves what the user typed and shows the error. +- Redirecting after success helps prevent an accidental duplicate form post. + +WHEN ModelState.AddModelError IS APPROPRIATE +Manual errors are still useful for rules that a simple attribute cannot express, +such as "this policy number already exists" after checking the database: + +ModelState.AddModelError( + nameof(model.PolicyNumber), + "That policy number already exists."); + +WEB FORMS NOTE +Setting an error label or using validator controls is familiar in ASP.NET Web +Forms. In ASP.NET MVC, validation attributes + ModelState + +asp-validation-for are the conventional equivalent.` + }, + + 'sql-policy-query': { + title: 'SQL Server Simple Join', + language: 'sql', + content: `-- 5-minute SQL Server question +-- +-- TABLES +-- +-- Customers +-- +------------+---------------+ +-- | CustomerId | Name | +-- +------------+---------------+ +-- | 1 | Alice Johnson | +-- | 2 | Bob Smith | +-- | 3 | Carla Gomez | +-- +------------+---------------+ +-- +-- Policies +-- +----------+------------+--------------+-----------+ +-- | PolicyId | CustomerId | PolicyNumber | Status | +-- +----------+------------+--------------+-----------+ +-- | 101 | 1 | AUTO-1001 | Active | +-- | 102 | 1 | HOME-2001 | Cancelled | +-- | 103 | 2 | AUTO-1002 | Active | +-- | 104 | 3 | LIFE-3001 | Pending | +-- +----------+------------+--------------+-----------+ +-- +-- CustomerId connects each policy to its customer. For example, +-- policy AUTO-1001 has CustomerId 1, so it belongs to Alice Johnson. +-- +-- TASK +-- Return the customer name and policy number for Active policies only. +-- +-- EXPECTED RESULT +-- +---------------+--------------+ +-- | Name | PolicyNumber | +-- +---------------+--------------+ +-- | Alice Johnson | AUTO-1001 | +-- | Bob Smith | AUTO-1002 | +-- +---------------+--------------+ + +SELECT + -- Write the two columns here +FROM Customers c + -- Add the JOIN to Policies here +WHERE + -- Keep only Active policies here +;`, + answerKey: `PLAIN-ENGLISH ANSWER +Select Name from Customers and PolicyNumber from Policies. Join the tables +where their CustomerId values match. Filter Status to Active. + +ONE GOOD SOLUTION +SELECT + c.Name, + p.PolicyNumber +FROM Customers c +INNER JOIN Policies p ON p.CustomerId = c.CustomerId +WHERE p.Status = 'Active'; + +HOW TO GRADE (0–3) +3 — Correct columns, JOIN condition, and Active filter. +2 — Right structure with one minor alias or syntax error. +1 — Can explain matching CustomerId but cannot write the JOIN. +0 — No relationship between the tables or uses unrelated columns. + +Important: INNER JOIN or JOIN are both correct here.` + }, + + 'ef-linq': { + title: 'LINQ Simple Filter', + language: 'csharp', + content: `// 5-minute LINQ question +// +// Each Employee object has these fields: +// - Id: the employee's unique number +// - Name: the employee's full name +// - Department: the team where the employee works +// - IsActive: true if the employee currently works for the company +// +// SAMPLE DATA +// Id | Name | Department | IsActive +// 1 | Carla Gomez | Claims | true +// 2 | Bob Smith | Sales | false +// 3 | Alice Johnson | Claims | true +// +// Complete the query so it returns the names of active employees, +// sorted alphabetically. +// +// EXPECTED RESULT +// Alice Johnson +// Carla Gomez + +using System.Collections.Generic; +using System.Linq; + +var employees = new List +{ + new Employee { Id = 1, Name = "Carla Gomez", Department = "Claims", IsActive = true }, + new Employee { Id = 2, Name = "Bob Smith", Department = "Sales", IsActive = false }, + new Employee { Id = 3, Name = "Alice Johnson", Department = "Claims", IsActive = true } +}; + +var names = employees + // Filter IsActive == true + // Sort by Name + // Select Name + .ToList(); + +public class Employee +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Department { get; set; } = ""; + public bool IsActive { get; set; } +}`, + answerKey: `PLAIN-ENGLISH ANSWER +Filter the list to active employees, sort those employees by name, and then +select just each employee's name. Bob is removed because IsActive is false. +Alice appears before Carla because OrderBy sorts the names alphabetically. + +ONE GOOD SOLUTION +var names = employees + .Where(e => e.IsActive) + .OrderBy(e => e.Name) + .Select(e => e.Name) + .ToList(); + +HOW TO GRADE (0–3) +3 — Correct Where, OrderBy, Select, and ToList sequence. +2 — Gets two of the three operations or fixes the third with a hint. +1 — Understands filtering but cannot express a LINQ lambda. +0 — Cannot identify IsActive as the filter. + +BONUS: With Entity Framework, this query is normally translated into SQL.` + }, + + 'ef-migration': { + title: 'Entity Framework — Add a Field', + language: 'csharp', + content: `// 5-minute Entity Framework Core question +// +// DAY-TO-DAY SCENARIO +// This Policy class already exists in a working application and its SQL Server +// table already contains policy records. +// +// The business now wants to store an optional renewal date. Old policies might +// not have a renewal date, so the new field must allow null. +// +// Complete these two tasks: +// 1. Add a nullable RenewalDate field to the Policy class. +// 2. Write the commands you would run to create and apply an EF Core migration. + +using System; + +public class Policy +{ + public int Id { get; set; } + public string PolicyNumber { get; set; } = ""; + public string CustomerName { get; set; } = ""; + public decimal Premium { get; set; } + + // TODO 1: Add the nullable RenewalDate property here. +} + +// TODO 2: Write the two EF Core commands as comments: +// Create migration: +// Apply migration:`, + answerKey: `PLAIN-ENGLISH ANSWER +Entity Framework maps the Policy class to a database table. Changing the C# +class alone does not update the existing SQL Server table. A migration records +the required schema change, and the database-update command applies it. + +The question says the date is optional. DateTime is a value type, so DateTime? +is used to allow null. Existing database rows can then have NULL in the new +RenewalDate column. + +ONE GOOD MODEL CHANGE +public DateTime? RenewalDate { get; set; } + +GOOD COMMANDS — .NET CLI +dotnet ef migrations add AddRenewalDateToPolicy +dotnet ef database update + +ALSO CORRECT — VISUAL STUDIO PACKAGE MANAGER CONSOLE +Add-Migration AddRenewalDateToPolicy +Update-Database + +The migration name is chosen by the developer; any clear name is acceptable. + +HOW TO GRADE (0-3) +3 — Adds DateTime?, creates a migration, and applies it with correct commands. +2 — Correct nullable property and remembers migrations, but misses or slightly + mistypes one command. +1 — Adds DateTime or DateTime? but does not know how the database is updated. +0 — Only edits SQL manually or cannot connect the model change to the database. + +WHAT TO LISTEN FOR +- Nullable avoids inventing a date for existing policies. +- The generated migration should be reviewed before applying it. +- Production migrations should follow the team's deployment process; developers + should not casually update a production database from their laptop. + +DO NOT PENALIZE +Accept either the dotnet CLI or Package Manager Console command style.` + }, + + 'solid-refactor': { + title: 'SOLID Dependency Injection', + language: 'csharp', + content: `// 5-minute SOLID question +// +// The repository is already passed into the constructor as an interface. +// The email sender is still created directly inside PolicyService. +// +// Refactor PolicyService so IEmailService is also constructor-injected: +// 1. Change the _email field to IEmailService. +// 2. Add IEmailService email to the constructor. +// 3. Assign it to _email. +// 4. Do not create EmailSender directly. +// +// Which SOLID letter does this change demonstrate most directly? + +public class PolicyService +{ + private readonly IPolicyRepository _policies; + private readonly EmailSender _email = new EmailSender(); + + public PolicyService(IPolicyRepository policies) + { + _policies = policies; + } + + public void CreatePolicy(Policy policy) + { + _policies.Save(policy); + _email.SendConfirmation(policy.CustomerEmail); + } +} + +// SOLID letter:`, + answerKey: `WHAT IS SOLID? +SOLID is a set of five guidelines for organizing object-oriented code so it is +easier to change, test, and maintain: + +S — Single Responsibility: + A class should have one main job or one reason to change. +O — Open/Closed: + Add new behavior without repeatedly rewriting stable existing code. +L — Liskov Substitution: + A replacement implementation should work wherever its interface is expected. +I — Interface Segregation: + Prefer small, focused interfaces over one large interface with unrelated jobs. +D — Dependency Inversion: + Business code should depend on interfaces, not directly on database or email + implementations. + +Do not expect the candidate to recite all five definitions. This exercise mainly +tests the D: Dependency Inversion Principle and basic constructor injection. + +WHAT IS WRONG WITH THIS CLASS? +The repository already follows Dependency Inversion: PolicyService depends on +IPolicyRepository rather than constructing a SQL repository. Email does not: +new EmailSender() permanently couples the class to that implementation. It is +harder to replace the email provider and harder to test without sending email. + +ONE GOOD SOLUTION +public class PolicyService +{ + private readonly IPolicyRepository _policies; + private readonly IEmailService _email; + + public PolicyService( + IPolicyRepository policies, + IEmailService email) + { + _policies = policies; + _email = email; + } + + public void CreatePolicy(Policy policy) + { + _policies.Save(policy); + _email.SendConfirmation(policy.CustomerEmail); + } +} + +// SOLID letter: D — Dependency Inversion + +WHY THIS HELPS +A production application can inject a real email service. A unit test can inject +a fake email service that records the call without sending anything. + +SINGLE RESPONSIBILITY CONNECTION +IPolicyRepository owns persistence and IEmailService owns email delivery. +PolicyService can reasonably coordinate the single "create a policy" workflow. +The clearest problem shown by new EmailSender() is the concrete dependency, so +Dependency Inversion is the primary answer. + +HOW TO GRADE (0–3) +3 — Injects IEmailService through the constructor, stores it in the field, + removes new EmailSender(), and identifies D/Dependency Inversion. +2 — Correctly performs the constructor refactor but cannot name the principle, + or identifies the principle with a small code mistake. +1 — Says an interface would help testing but cannot wire it into the constructor. +0 — Leaves new EmailSender() in place or cannot explain the direct dependency. + +INTERVIEWER TIP +IEmailService, INotificationService, or IMailer are all reasonable names. Give +credit for practical understanding even if the candidate calls this dependency +injection without remembering the words "Dependency Inversion."` + }, + + 'csharp-debugging': { + title: 'C# OOP — Model Customers and Policies', + language: 'csharp', + content: `// 5-minute C# object-oriented design question +// +// We are continuing the small insurance application from the warm-up. +// Right now, its information is written as one loose list: +// +// Customer ID: 1 +// Customer name: Alice Johnson +// Customer email: alice@example.com +// Policy ID: 101 +// Policy number: AUTO-1001 +// Status: Active +// Premium: 125.50 +// +// YOUR TASK +// Break this information into two C# classes: Customer and Policy. +// +// Customer should contain: +// - Id +// - Name +// - Email +// +// Policy should contain: +// - Id +// - PolicyNumber +// - Status +// - Premium +// - A Customer property connecting the policy to its owner +// +// Choose sensible C# types and create both classes below. + +`, + answerKey: `PLAIN-ENGLISH ANSWER +The customer and policy are different concepts, so each gets its own class. +Policy refers to a Customer object instead of repeating the customer's name and +email. This relationship is called composition: one object contains or refers +to another object. + +ONE GOOD SOLUTION +public class Customer +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Email { get; set; } = ""; +} + +public class Policy +{ + public int Id { get; set; } + public string PolicyNumber { get; set; } = ""; + public string Status { get; set; } = ""; + public decimal Premium { get; set; } + public Customer Customer { get; set; } = new Customer(); +} + +WHY THESE TYPES? +- int is reasonable for the sample IDs. +- string fits names, email addresses, policy numbers, and the simple status. +- decimal is normally preferred over double for money. +- Customer connects the two objects and keeps customer details together. + +HOW TO GRADE (0-3) +3 — Creates both classes, places fields on the appropriate class, uses sensible + types, and connects Policy to Customer. +2 — Creates both classes with most fields but misses the relationship or makes + a small type/syntax mistake. +1 — Creates only one large class or needs substantial help separating the data. +0 — Cannot translate the described information into C# classes and properties. + +ACCEPTABLE VARIATIONS +- Fields instead of properties are acceptable for this short exercise, although + properties are conventional in C# application models. +- CustomerId on Policy is a reasonable database-oriented answer. Ask how the + code would access the customer's name; adding Customer as well is stronger. +- An enum for Status is a good enhancement but is not required. +- Constructors are optional. Auto-properties are enough for full credit. + +HOW THIS CONNECTS TO LATER QUESTIONS +The later SQL, MVC, Entity Framework, LINQ, and SOLID exercises build on these +same Customer and Policy ideas. The candidate does not need every production +field yet; this is only the starting model.` + }, + + 'ai-prompting': { + title: 'AI Prompting — Describe a .NET Change', + language: 'markdown', + content: `# 5-minute AI prompting question + +## Scenario + +You are working on an existing ASP.NET Core MVC insurance application that uses +Entity Framework Core and SQL Server. + +The Policies Index page already lists every policy. The product owner asks you +to add a search box so users can find policies by customer name. + +The requested behavior is: + +- Add a customer-name search box to the existing Policies Index page. +- An empty search should show every policy. +- A non-empty search should show policies whose CustomerName contains the text. +- Filter in the database with Entity Framework rather than loading every row + into memory first. +- Keep the user's search text visible in the box after submitting. +- Add tests for an empty search and a matching search. +- Do not change unrelated application behavior. + +## Your task + +Write the prompt you would give an AI coding assistant to implement this change. + +There is no single exact answer. Your prompt should give the AI enough context +to make a useful change and explain how you would check its work. + +Write your AI prompt below: + +`, + answerKey: `WHAT THIS QUESTION TESTS +This is not a test of finding magic words. A good developer treats an AI like a +new teammate: provide context, describe the desired behavior, set boundaries, +request verification, and review the result instead of trusting it blindly. + +ONE STRONG EXAMPLE PROMPT +We have an existing ASP.NET Core MVC application using Entity Framework Core +and SQL Server. Please inspect the current Policy model, PoliciesController, +Policies/Index.cshtml view, DbContext, and existing test conventions before +editing anything. + +Add customer-name search to the existing Policies Index page: +- Add a GET search input named customerName to the Razor view. +- Accept the optional value in the Index action. +- If it is blank, return all policies. +- Otherwise, filter by CustomerName in the IQueryable before ToListAsync so + SQL Server performs the filtering. +- Preserve the entered value in the search box after submission. +- Follow the project's existing naming and styling patterns. +- Do not modify unrelated files or add a migration unless the model changes. +- Add tests for a blank search and a matching search. + +Before editing, tell me which files you expect to change and ask about anything +the repository does not make clear. After editing, summarize the changes and +run the relevant build and tests. Show me any failures instead of hiding them. + +WHY THIS IS STRONG +- It identifies the technology and asks the AI to inspect the real project. +- It translates the request into specific, observable behavior. +- It prevents an inefficient in-memory filter. +- It limits unrelated changes and unnecessary migrations. +- It asks for tests, build verification, and honest reporting of failures. + +HOW TO GRADE (0-3) +3 — Includes project context, clear requirements, important constraints, and a + request to test or verify the work. +2 — Describes the feature clearly but misses either technical context, + boundaries, or verification. +1 — Gives a vague request such as "add search" with little useful detail. +0 — Provides no workable prompt or expects the AI output to be trusted without + review. + +GOOD FOLLOW-UP QUESTION +Ask: "What would you personally check before accepting the AI's code?" + +Good answers include reviewing the diff, checking that filtering happens before +ToListAsync, running tests, trying empty and matching searches, checking error +handling, and confirming unrelated files were not changed. + +INTERVIEWER TIP +Do not grade grammar or prompt length. A shorter prompt can earn full credit if +it communicates the context, desired outcome, constraints, and verification.` + }, + + 'frontend-css': { + title: 'HTML/CSS — Responsive Policy Card', + language: 'html', + content: ` + + + + +
+

Policy AUTO-1001

+

Customer: Alice Johnson

+

Status: Active

+ +
`, + answerKey: `PLAIN-ENGLISH ANSWER +width sets the card's width. Padding adds space between the content and the +border. The h2 and p selectors style those element types inside the card. The +media query applies different styles when the screen is 480px wide or smaller. + +ONE GOOD SOLUTION +.policy-card { + width: 400px; + padding: 16px; + border: 1px solid gray; +} + +.policy-card h2 { + font-family: Arial, sans-serif; + font-size: 24px; + font-weight: bold; +} + +.policy-card p { + font-family: Arial, sans-serif; + font-weight: normal; +} + +.details-button { + width: auto; + padding: 10px 16px; + border: none; + background-color: blue; + color: white; + font-family: Arial, sans-serif; + font-weight: bold; +} + +@media (max-width: 480px) { + .policy-card { + width: 200px; + } + + .details-button { + width: 100%; + } +} + +HOW TO GRADE (0-3) +3 — Adds the card layout, requested heading and paragraph typography, button + styling, and the 480px media rule that changes the card width to 200px. +2 — Completes most changes but misses one detail or makes a minor syntax error. +1 — Can add one or two basic properties but needs substantial help with the + selectors or remaining styles. +0 — Does not know which CSS properties control these visible changes. + +ACCEPTABLE VARIATIONS +- Colors such as #999, #ccc, or another reasonable gray are fine. +- A reasonable blue such as #0066cc, royalblue, or Bootstrap blue is fine. +- A close breakpoint such as 500px is fine if the candidate explains it. +- Equivalent selectors and reasonable pixel/rem values are fine. +- The full-width mobile button is a bonus and should not affect the main score. + +OPTIONAL FOLLOW-UP +Ask the candidate to explain the difference between margin and padding: +padding is inside the element's border; margin is outside the border.` + }, + + 'dotnet-interview-plan': { + title: 'Introduction', + language: 'markdown', + content: `# Welcome to your .NET interview + +You will work through ten short exercises. Each one is intended to take about +five minutes. The goal is to understand how you approach a problem—not to test +whether you have memorized every piece of syntax. + +## What we will cover + +1. C# — Discounted premium warm-up +2. C# — Model Customers and Policies +3. SQL Server — Simple Join +4. LINQ — Simple Filter +5. Entity Framework — Add a Field and Migration +6. ASP.NET MVC — Validation +7. C# — REST API Basics +8. SOLID — Constructor Injection +9. AI — Prompt a Coding Assistant +10. HTML/CSS — Responsive Policy Card + +## How to approach each exercise + +- Read the prompt carefully and ask questions if anything is unclear. +- Talk through what you are thinking as you work. +- Write the clearest solution you can; small syntax mistakes are okay. +- You may write notes or pseudocode before writing the final code. +- If you get stuck, say where you are stuck. The interviewer may offer a hint. +- Explain your finished answer and how you would check that it works. + +It is completely okay to say that you do not remember something. Clear +reasoning, honest communication, and learning from a hint are all valuable.`, + answerKey: `OVERALL GRADING +Each exercise has a 0–3 score in its own Answer Key. + +Suggested total for 10 questions: 30 points +27–30 — Strong performance +20–26 — Reasonable performance; discuss weak areas +12–19 — Significant gaps; consider experience claims carefully +0–11 — Fundamentals were not demonstrated + +This is only a guide. Communication, honesty, response to hints, and ability +to explain their own resume should influence the final decision. + +Good signs: +- Explains what they are doing. +- Tests the result instead of guessing. +- Accepts a hint and applies it. +- Says when they do not know something. + +Warning signs: +- Cannot explain code they just wrote. +- Gives memorized terms without concrete meaning. +- Claims the editor is the problem before reading simple errors. +- Resume examples cannot be described in practical detail.` + } +}; diff --git a/api/interview/templates.js b/api/interview/templates.js new file mode 100644 index 0000000..caaf617 --- /dev/null +++ b/api/interview/templates.js @@ -0,0 +1,196 @@ +'use strict'; + +const jwt = require('jsonwebtoken'); +const admin = require('firebase-admin'); +const templateData = require('./templates-data'); + +if (!admin.apps.length && process.env.FIREBASE_PROJECT_ID) { + admin.initializeApp({ + credential: admin.credential.cert({ + projectId: process.env.FIREBASE_PROJECT_ID, + clientEmail: process.env.FIREBASE_CLIENT_EMAIL, + privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n') + }), + databaseURL: process.env.FIREBASE_DATABASE_URL + }); +} + +const templateOrder = [ + 'dotnet-interview-plan', + 'csharp-warmup', + 'csharp-debugging', + 'sql-policy-query', + 'ef-linq', + 'ef-migration', + 'aspnet-mvc', + 'csharp-rest-api', + 'solid-refactor', + 'ai-prompting', + 'frontend-css' +]; + +const templates = Object.fromEntries( + templateOrder + .filter(key => templateData[key]) + .map(key => [key, templateData[key]]) +); + +const questionSets = { + 'complete-dotnet': { + title: 'Complete .NET Interview', + description: 'The full interview, including the candidate overview.', + questions: templateOrder + } +}; + +module.exports = async function handler(req, res) { + res.setHeader('Cache-Control', 'private, no-store'); + + if (!['GET', 'POST'].includes(req.method)) { + return res.status(405).json({ error: 'Method not allowed' }); + } + + const jwtSecret = process.env.JWT_SECRET; + if (!jwtSecret) { + console.error('Missing required environment variable: JWT_SECRET'); + return res.status(503).json({ error: 'Interview templates are unavailable' }); + } + + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authentication required' }); + } + + try { + const decoded = jwt.verify(authHeader.substring(7), jwtSecret); + if (decoded.isAdmin !== true) { + return res.status(403).json({ error: 'Interviewer access required' }); + } + + if (!admin.apps.length) { + return res.status(503).json({ error: 'Question-set storage is unavailable' }); + } + + if (req.method === 'POST') { + const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body; + const name = String(body?.name || '').trim(); + const description = String(body?.description || '').trim(); + const questions = body?.questions; + const importedId = typeof body?.id === 'string' ? body.id.trim() : ''; + + if (!name || name.length > 120) { + return res.status(400).json({ error: 'Question set name is required (120 characters maximum)' }); + } + if (!Array.isArray(questions) || questions.length < 1 || questions.length > 50) { + return res.status(400).json({ error: 'Provide between 1 and 50 questions' }); + } + + const allowedLanguages = new Set([ + 'javascript', 'python', 'java', 'c_cpp', 'csharp', 'php', 'ruby', + 'go', 'rust', 'typescript', 'swift', 'kotlin', 'scala', 'r', 'perl', + 'lua', 'haskell', 'elixir', 'dart', 'html', 'css', 'sql', 'json', + 'yaml', 'xml', 'markdown' + ]); + const normalizedQuestions = questions.map((question, index) => { + const title = String(question?.title || '').trim(); + const language = String(question?.language || '').trim(); + const content = String(question?.content || ''); + const answerKey = String(question?.answerKey || ''); + if (!title || !allowedLanguages.has(language) || !content.trim() || !answerKey.trim()) { + throw Object.assign(new Error(`Question ${index + 1} is missing a valid title, language, content, or answer key`), { status: 400 }); + } + if (title.length > 120 || content.length > 50000 || answerKey.length > 50000) { + throw Object.assign(new Error(`Question ${index + 1} exceeds the allowed size`), { status: 400 }); + } + return { title, language, content, answerKey }; + }); + + const validImportedId = /^[A-Za-z0-9_-]{1,80}$/.test(importedId); + const setId = validImportedId + ? (importedId.startsWith('custom-') ? importedId : `custom-${importedId}`) + : `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const storedSet = { + name, + description: description.slice(0, 300), + questions: normalizedQuestions, + createdAt: Date.now(), + createdBy: decoded.email + }; + await admin.database().ref().update({ + [`customQuestionSets/${setId}`]: storedSet, + [`customQuestionSetMetadata/${setId}`]: { + name: storedSet.name, + description: storedSet.description, + questionCount: normalizedQuestions.length, + createdAt: storedSet.createdAt, + createdBy: storedSet.createdBy + } + }); + return res.status(201).json({ success: true, setId }); + } + + const metadataRef = admin.database().ref('customQuestionSetMetadata'); + let metadataSnapshot = await metadataRef.once('value'); + let customSetMetadata = metadataSnapshot.val() || {}; + + // Backfill metadata for sets saved before the lightweight index existed. + if (!metadataSnapshot.exists()) { + const legacySnapshot = await admin.database().ref('customQuestionSets').once('value'); + const legacySets = legacySnapshot.val() || {}; + customSetMetadata = Object.fromEntries( + Object.entries(legacySets).map(([setId, set]) => [setId, { + name: set.name, + description: set.description || '', + questionCount: Object.keys(set.questions || {}).length, + createdAt: set.createdAt, + createdBy: set.createdBy + }]) + ); + if (Object.keys(customSetMetadata).length) { + await metadataRef.set(customSetMetadata); + } + } + + const responseTemplates = { ...templates }; + const responseQuestionSets = { ...questionSets }; + const requestedSetId = String(req.query?.setId || ''); + + Object.entries(customSetMetadata).forEach(([setId, set]) => { + const questionKeys = Array.from( + { length: Number(set.questionCount) || 0 }, + (_, index) => `${setId}:${index}` + ); + responseQuestionSets[setId] = { + title: set.name, + description: set.description || '', + questions: questionKeys, + custom: true + }; + }); + + if (requestedSetId && responseQuestionSets[requestedSetId]?.custom) { + const selectedSnapshot = await admin.database() + .ref(`customQuestionSets/${requestedSetId}`) + .once('value'); + const selectedSet = selectedSnapshot.val(); + Object.values(selectedSet?.questions || {}).forEach((question, index) => { + responseTemplates[`${requestedSetId}:${index}`] = question; + }); + } + + return res.status(200).json({ + templates: responseTemplates, + questionSets: responseQuestionSets + }); + } catch (error) { + if (error instanceof SyntaxError) { + return res.status(400).json({ error: 'Invalid JSON body' }); + } + if (error.status) return res.status(error.status).json({ error: error.message }); + if (['JsonWebTokenError', 'TokenExpiredError', 'NotBeforeError'].includes(error.name)) { + return res.status(401).json({ error: 'Invalid or expired authentication' }); + } + console.error('Interview template error:', error); + return res.status(500).json({ error: 'Interview templates are unavailable' }); + } +}; diff --git a/app.html b/app.html index c4dba1f..08c7c71 100644 --- a/app.html +++ b/app.html @@ -22,7 +22,7 @@ - + @@ -44,7 +44,7 @@ - + - @@ -319,6 +321,7 @@

Active Session

Copy + @@ -334,11 +337,32 @@

Active Session

Session Manager View All + + +