-
Notifications
You must be signed in to change notification settings - Fork 2
Feature/attendee creation with team assignment #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c27ba75
68d3151
dfda70c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| # Admin and Staff Routes | ||
|
|
||
| ## 🔐 Admin Routes | ||
| *Requires `role === "admin"` or `role === "staff"` in registrations collection* | ||
|
|
||
| ### Main Dashboard | ||
| - **`/admin`** - Admin control panel with all tools | ||
|
|
||
| ### Settings & Configuration | ||
| - **`/admin/settings`** ⚙️ - Event configuration | ||
| - Toggle leaderboard visibility | ||
| - Control event settings | ||
|
|
||
| ### Team Management | ||
| - **`/admin/teams`** 👥 - Team management | ||
| - **Create new participants/registrations** (fill all fields manually) | ||
| - Create teams from registrations | ||
| - Assign team leaders | ||
| - Confirm payment status | ||
| - View team details | ||
|
|
||
| ### Judge Management | ||
| - **`/admin/judge-assignments`** 📋 - Judge assignment system | ||
| - Assign judges to teams | ||
| - Manage judging schedules | ||
| - View assignment status | ||
|
|
||
| ### Milestones & Judging | ||
| - **`/admin/milestones`** 📝 - Milestone overview | ||
| - **`/admin/milestones/[id]`** 🎯 - Individual milestone judging | ||
| - Score teams on assigned milestones | ||
| - Enter rubric scores | ||
| - Add time bonuses | ||
| - View real-time scoring | ||
|
|
||
| --- | ||
|
|
||
| ## 👔 Staff Routes | ||
| *Requires `role === "staff"` or `role === "admin"` in registrations collection* | ||
|
|
||
| ### QR Scanner | ||
| - **`/staff/scanner`** 📱 - Multi-purpose QR code scanner | ||
| - Check-in participants | ||
| - Distribute swag items | ||
| - Photobooth access control | ||
| - Real-time attendance tracking | ||
|
|
||
| ### Analytics Dashboard | ||
| - **`/staff/analytics`** 📊 - Event analytics | ||
| - Check-in statistics | ||
| - Swag distribution tracking | ||
| - Photobooth usage stats | ||
| - Export attendance data (CSV) | ||
| - Real-time event metrics | ||
|
|
||
| --- | ||
|
|
||
| ## 🏆 Public Routes (No Authentication Required) | ||
|
|
||
| ### Leaderboard | ||
| - **`/leaderboard`** - Team rankings and scores | ||
| - Real-time score updates | ||
| - Filter by milestone | ||
| - Filter by classroom | ||
| - Top 3 podium display | ||
| - *Can be hidden by admins via settings* | ||
|
|
||
| --- | ||
|
|
||
| ## 📋 Participant Routes | ||
| *Requires authentication with `role === "participant"` or `role === "leader"`* | ||
|
|
||
| ### Dashboard | ||
| - **`/dashboard`** - Participant home page | ||
| - **`/team`** - Team information | ||
| - **`/profile`** - User profile | ||
|
|
||
| ### Registration & Team Creation | ||
| - **`/register`** - Event registration | ||
| - **`/create-team`** - Create new team | ||
| - **`/edit-team`** - Edit team details | ||
| - **`/view-team`** - View team information | ||
|
|
||
| ### Hackathon Resources | ||
| - **`/hackathon/milestone_one`** - Milestone 1 details | ||
| - **`/hackathon/milestone_two`** - Milestone 2 details | ||
| - **`/hackathon/milestone_three`** - Milestone 3 details | ||
| - **`/hackathon/milestone_four`** - Milestone 4 details | ||
|
|
||
| ### Information Pages | ||
| - **`/schedule`** - Event schedule | ||
| - **`/timeline`** - Event timeline | ||
| - **`/faq`** - Frequently asked questions | ||
| - **`/coc`** - Code of Conduct | ||
|
|
||
| --- | ||
|
|
||
| ## 🔑 Access Control Summary | ||
|
|
||
| | Role | Admin Panel | Staff Tools | Judging | Team Mgmt | Settings | | ||
| |------|------------|-------------|---------|-----------|----------| | ||
| | **Admin** | ✅ Full | ✅ Full | ✅ Yes | ✅ Yes | ✅ Yes | | ||
| | **Staff** | ✅ Limited | ✅ Full | ❌ No | ✅ View | ❌ No | | ||
| | **Leader** | ❌ No | ❌ No | ❌ No | ✅ Own Team | ❌ No | | ||
| | **Participant** | ❌ No | ❌ No | ❌ No | ❌ No | ❌ No | | ||
|
|
||
| --- | ||
|
|
||
| ## 📝 Notes | ||
|
|
||
| ### Admin Settings Page Features: | ||
| 1. **Leaderboard Visibility Control** | ||
| - Toggle leaderboard on/off | ||
| - Real-time updates for all users | ||
| - Useful for hiding scores between milestones | ||
|
|
||
| ### Staff vs Admin Access: | ||
| - **Staff** can access most tools but cannot: | ||
| - Assign judges (admin only) | ||
| - Access admin settings | ||
| - Modify core configurations | ||
|
|
||
| - **Admin** has full system access including: | ||
| - All staff capabilities | ||
| - Judge assignment system | ||
| - Event configuration settings | ||
| - System-wide controls | ||
|
|
||
| ### Security: | ||
| All routes check authentication on page load: | ||
| ```typescript | ||
| getDoc(doc(db, "registrations", user.uid)).then((document) => { | ||
| const response = document.data(); | ||
| if (response.role !== "admin" && response.role !== "staff") { | ||
| alert("Admin access required"); | ||
| window.location.href = "/"; | ||
| } | ||
| }); | ||
| ``` |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| import { initializeApp, cert, getApps } from 'firebase-admin/app'; | ||
| import { getFirestore, Timestamp } from 'firebase-admin/firestore'; | ||
| import * as fs from 'fs'; | ||
| import * as path from 'path'; | ||
|
|
||
| // Initialize Firebase Admin | ||
| if (!getApps().length) { | ||
| initializeApp({ | ||
| credential: cert(require('./serviceAccountKey.json')) | ||
| }); | ||
| } | ||
|
|
||
| const db = getFirestore(); | ||
|
|
||
| interface Registration { | ||
| uid: string; | ||
| firstName: string; | ||
| lastName: string; | ||
| email: string; | ||
| gender: string; | ||
| university: string; | ||
| otherUniversity: string; | ||
| displayPicture: string; | ||
| payment_status: string; | ||
| teamCode: string; | ||
| role: string; | ||
| createdAt: string; | ||
| updatedAt: string; | ||
| } | ||
|
|
||
| function parseCSV(filePath: string): any[] { | ||
| const content = fs.readFileSync(filePath, 'utf-8'); | ||
| const lines = content.split('\n').filter(line => line.trim()); | ||
|
|
||
| if (lines.length === 0) return []; | ||
|
|
||
| const headers = lines[0].split(',').map(h => h.trim()); | ||
| const rows: any[] = []; | ||
|
|
||
| for (let i = 1; i < lines.length; i++) { | ||
| const values: string[] = []; | ||
| let currentValue = ''; | ||
| let insideQuotes = false; | ||
|
|
||
| for (let char of lines[i]) { | ||
| if (char === '"') { | ||
| insideQuotes = !insideQuotes; | ||
| } else if (char === ',' && !insideQuotes) { | ||
| values.push(currentValue.trim()); | ||
| currentValue = ''; | ||
| } else { | ||
| currentValue += char; | ||
| } | ||
| } | ||
| values.push(currentValue.trim()); | ||
|
|
||
| if (values.length === headers.length) { | ||
| const row: any = {}; | ||
| headers.forEach((header, index) => { | ||
| row[header] = values[index] || ''; | ||
| }); | ||
| rows.push(row); | ||
| } | ||
| } | ||
|
|
||
| return rows; | ||
| } | ||
|
|
||
| async function reseedStaffAndAdmin() { | ||
| console.log('🔄 Re-seeding staff and admin accounts with correct UIDs...'); | ||
|
|
||
| const staffAdmin = parseCSV(path.join(__dirname, 'staff-admin-correct.csv')) as Registration[]; | ||
| console.log(`Found ${staffAdmin.length} staff/admin accounts to re-seed\n`); | ||
|
|
||
| let adminCount = 0; | ||
| let staffCount = 0; | ||
| let errorCount = 0; | ||
|
|
||
| for (const account of staffAdmin) { | ||
| try { | ||
| const { uid, createdAt, updatedAt, ...data } = account; | ||
|
|
||
| await db.collection('registrations').doc(uid).set({ | ||
| ...data, | ||
| createdAt: Timestamp.now(), | ||
| updatedAt: Timestamp.now(), | ||
| }); | ||
|
|
||
| if (data.role === 'admin') { | ||
| adminCount++; | ||
| console.log(` ✓ Admin: ${data.firstName} ${data.lastName} (${data.email})`); | ||
| } else { | ||
| staffCount++; | ||
| console.log(` ✓ Staff: ${data.firstName} ${data.lastName} (${data.email})`); | ||
| } | ||
| } catch (error) { | ||
| console.error(` ✗ Error seeding account ${account.uid}:`, error); | ||
| errorCount++; | ||
| } | ||
| } | ||
|
|
||
| console.log('\n' + '='.repeat(70)); | ||
| console.log('📊 RE-SEEDING SUMMARY'); | ||
| console.log('='.repeat(70)); | ||
| console.log(`✅ Admins: ${adminCount} accounts`); | ||
| console.log(`✅ Staff: ${staffCount} accounts`); | ||
| console.log(`❌ Errors: ${errorCount}`); | ||
| console.log('='.repeat(70)); | ||
|
|
||
| if (errorCount === 0) { | ||
| console.log('✅ All staff and admin accounts re-seeded successfully!'); | ||
| } else { | ||
| console.log('⚠️ Re-seeding completed with some errors'); | ||
| } | ||
|
|
||
| return { adminCount, staffCount, errorCount }; | ||
| } | ||
|
|
||
| async function main() { | ||
| console.log('🚀 Starting staff/admin re-seeding...\n'); | ||
| console.log('=' .repeat(70)); | ||
|
|
||
| const startTime = Date.now(); | ||
|
|
||
| try { | ||
| await reseedStaffAndAdmin(); | ||
|
|
||
| const endTime = Date.now(); | ||
| const duration = ((endTime - startTime) / 1000).toFixed(2); | ||
|
|
||
| console.log(`\n⏱️ Duration: ${duration}s`); | ||
|
|
||
| process.exit(0); | ||
| } catch (error) { | ||
| console.error('❌ Fatal error during re-seeding:', error); | ||
| process.exit(1); | ||
| } | ||
| } | ||
|
|
||
| main(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -151,28 +151,99 @@ async function seedTeams() { | |
| return { successCount, errorCount }; | ||
| } | ||
|
|
||
| async function seedStaffAndAdmin() { | ||
| console.log('👨💼 Seeding staff and admin accounts...'); | ||
|
|
||
| const staffAdmin = parseCSV(path.join(__dirname, 'staff-admin-seed.csv')) as Registration[]; | ||
| console.log(`Found ${staffAdmin.length} staff/admin accounts to seed`); | ||
|
|
||
| let successCount = 0; | ||
| let errorCount = 0; | ||
|
|
||
| for (const account of staffAdmin) { | ||
| try { | ||
| const { uid, createdAt, updatedAt, ...data } = account; | ||
|
|
||
| await db.collection('registrations').doc(uid).set({ | ||
| ...data, | ||
| createdAt: Timestamp.now(), | ||
| updatedAt: Timestamp.now(), | ||
| }); | ||
|
|
||
| successCount++; | ||
| console.log(` ✓ Seeded ${data.role} account: ${data.firstName} ${data.lastName}`); | ||
| } catch (error) { | ||
| console.error(` ✗ Error seeding account ${account.uid}:`, error); | ||
| errorCount++; | ||
| } | ||
| } | ||
|
|
||
| console.log(`✅ Staff/Admin complete: ${successCount} success, ${errorCount} errors\n`); | ||
| return { successCount, errorCount }; | ||
| } | ||
|
|
||
| async function seedTestTeam() { | ||
| console.log('🧪 Seeding test team...'); | ||
|
|
||
| const teams = parseCSV(path.join(__dirname, 'test-team-seed.csv')) as Team[]; | ||
| console.log(`Found ${teams.length} test team to seed`); | ||
|
|
||
| let successCount = 0; | ||
| let errorCount = 0; | ||
|
|
||
| for (const team of teams) { | ||
| try { | ||
| const { teamCode, createdAt, memberIds, ...data } = team; | ||
|
|
||
| // Parse memberIds JSON array string | ||
| let memberIdsArray: string[] = []; | ||
| try { | ||
| memberIdsArray = JSON.parse(memberIds); | ||
| } catch (e) { | ||
| console.error(` ⚠ Warning: Could not parse memberIds for ${teamCode}`); | ||
| memberIdsArray = []; | ||
| } | ||
|
|
||
| await db.collection('teams').doc(teamCode).set({ | ||
| ...data, | ||
| memberIds: memberIdsArray, | ||
| createdAt: Timestamp.now(), | ||
| }); | ||
|
|
||
| successCount++; | ||
| console.log(` ✓ Seeded test team ${teamCode} (${memberIdsArray.length} members)`); | ||
| } catch (error) { | ||
| console.error(` ✗ Error seeding team ${team.teamCode}:`, error); | ||
| errorCount++; | ||
| } | ||
| } | ||
|
|
||
| console.log(`✅ Test team complete: ${successCount} success, ${errorCount} errors\n`); | ||
| return { successCount, errorCount }; | ||
| } | ||
|
|
||
| async function main() { | ||
| console.log('🚀 Starting Firestore seeding...\n'); | ||
| console.log('=' .repeat(70)); | ||
|
|
||
| const startTime = Date.now(); | ||
|
||
|
|
||
| try { | ||
| const registrationStats = await seedRegistrations(); | ||
| const teamStats = await seedTeams(); | ||
| const staffAdminStats = await seedStaffAndAdmin(); | ||
| const testTeamStats = await seedTestTeam(); | ||
|
|
||
| const endTime = Date.now(); | ||
| const duration = ((endTime - startTime) / 1000).toFixed(2); | ||
|
|
||
| console.log('=' .repeat(70)); | ||
|
||
| console.log('📊 SEEDING SUMMARY'); | ||
| console.log('=' .repeat(70)); | ||
| console.log(`Registrations: ${registrationStats.successCount} seeded, ${registrationStats.errorCount} errors`); | ||
| console.log(`Teams: ${teamStats.successCount} seeded, ${teamStats.errorCount} errors`); | ||
| console.log(`Staff/Admin Accounts: ${staffAdminStats.successCount} seeded, ${staffAdminStats.errorCount} errors`); | ||
| console.log(`Test Team: ${testTeamStats.successCount} seeded, ${testTeamStats.errorCount} errors`); | ||
| console.log(`Duration: ${duration}s`); | ||
| console.log('=' .repeat(70)); | ||
|
|
||
|
||
| if (registrationStats.errorCount === 0 && teamStats.errorCount === 0) { | ||
| if (staffAdminStats.errorCount === 0 && testTeamStats.errorCount === 0) { | ||
| console.log('✅ All data seeded successfully!'); | ||
| } else { | ||
| console.log('⚠️ Seeding completed with some errors'); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,8 @@ | ||
| { | ||
| "type": "service_account", | ||
| "project_id": "techsprint-gitam", | ||
| "private_key_id": "7fe570a3ea76baa41a18d1e29242c9f66527446c", | ||
| "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDCURSj2oWsFXgg\n74iOhJ9dgdznsuc/Kb5wMhHsrDbpO09VAWqX4tv8H0/XQk2pmOAeAv0EmmwY5DbS\nQ2ZYGjs2AyJO8Buubq2GDaYdlzILn8BWR3FdrRKm4idsrn/PmuvfMYCo8KM2AOv7\n+uzq7IW0k0OSeBt3FDg8zUMXA2//rKjLzOl295oDTtGpmzIULAOI6SgGX6GJfpe/\nwkHwpN+ct08LJJgGYvFdqCKneqz2o7oTjcvW6ouRgAf8Jbx6X7A/nNr02muz4rtt\nG5WtEI3iOaLLnGVJJlWGzrwVeWmuC04v2lkLwciJFNbe78rZCfqHwXOQ8/URGLVq\nBCWE0E1NAgMBAAECggEATOnT/iFOPLWIxZyaVDMJc3UmD64AGz+2Kemfr6rg10OL\nHK4BV5pLkcmBDEapv+ILf8WWCb7n35hhXKuh9Gh5nGD0MQOYKVyUoZWAdYD1paU3\nd88yf640TksA6ONPIskC6ObKstQA/iyyO5xwL3KsX7PUkMKquGEP+30Ru6e4Kp4n\nQP3Tf0zBq92C/E3ff6E/zeNN7jaIU/cnV/OI9y6MDOcX+UjPDLZDZ+yNp5Lx40lp\nXAvZvQGQRHbt/l7J5/65vRg7zoSuzXeRfYPLCG3pCWTn5FatPfM43rQPIorkeOul\n4j0I1rNgpvIpJN6ghiFDahyytmNMPgTOJr4v8c4UDwKBgQDzBCHfxGgBcbH0knKt\nQc2z/IAl/j/DQe2b4QGDCKjZLaoS9Ym3blb2Xa8qRcsr7rIBMgbTruEZ4U/NEHmq\nCPPwuNyCfoTAf67WCvLW9+EvBpy4574NdBG1a51zCZsDAbu/Iz2cyVniyTh7OsIC\nOOxxs/F1LOiqlg4XvUViQhp1ZwKBgQDMstvtfxA3LEsloyremkHa2Wt405Vsncts\naYS4TopPcDMyLqx5lDMof3POPul+PGOAmtro+EYu+GkrEsi7qhiDTd5De9dsuTR5\nSQ9mzL4GAvSKTxi8UcwOs3K3d3ejQ5jrg/m2g3LVXnzfQqiB5048e+ueOBaxZ0xw\nUAoykLKjKwKBgFQmrCw2cOV/H2ZXiApi7P5Ug3OklSPiIouF4OYlC4MZAvnJuMSi\nGs75Jfz3aiFuaIltb1vCBQTXNrEF8Xtl2kMTYJh3gzS9gidwZyL1dy63lXGaHf++\nn5s5Bq6dNuZVpVPMujsepleX4k0ZzbDDUW0WKJiw0mivyXWC/xHFXjAlAoGAeuy9\n3bV8S2WyCvwddmg0O/Rs8bY9+WgZDRWguf2QWXwLgos80BYLUrqXFLf7B+/D4Ssd\nYuIVY4eRwGgbW3ceGVvdqbDpAUWHGX6iXR1+z6VerOAq/owwenOQ5FQ96DFj16r9\nfnkZsMB5RKmG/9ujw/a22+Da39YktR2bwhna7NkCgYAHYKt5D212pXxrL2IHYSqK\nEP3Z8CDy3b6rVm+A7ansYekaENcyfBu0zObvUMfysRzSYmczGfLS2HXfpK658b6q\nkM4XXEzkRZVAlCvG+X6ySUPVIWB5nsYep0dNce26eQTkw1xWgGejxE9/RCi/b1wI\nRC+Otyc9niLIxmVUwiPDBg==\n-----END PRIVATE KEY-----\n", | ||
| "private_key_id": "fe0c532876c7456432194af575556291e08f5fee", | ||
| "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD5Hv/O51kWbuoF\nOyLMZxb91d7So5V9eUomZarE7+gJ3Uz3g6T6cPTqgYrApPgiC+zvoSvkb15r2kMf\nUPVbqXSJd/ZkVCo3Q1ShSU4+rDGxDDeKx1ocE0wVZCEhLSKaHq2xIvhfLMEY9jh4\n0EyFR+uk3e/nWEY0+DkCczjSJz/0asfHAzY+p1JRR8P07bQsI+QM8xFUnRIgIuOB\nTxSvF1tNfRoa47ldlBZJTy0xgRlfzFk8KIdZrPWMIrfAUJnl5/LJvVlsA98Tv0sA\nhFtjfBMd1SM6sb83rmpmaH6DeeVyisfRRlxDILDhWFLL22Cwmwqn5pkCuG93MxYR\nA6FrkpQfAgMBAAECggEAb2p4URJx6xEO8+j3TsCabUs+HSnRPW1GBvc4UVzUg7jo\nZ9iGLAXh25G1OyRs31lDDgcgqMlQSt8yXuqn2WdnueWfmk50FQQ1cO7moiwEC2Fh\n49z1xZGx2O1PzdUwYQpwd0UjHPnYoK/aINpUJtW8I5+o4Gg/+ge8A4cBtiecuFQ5\ngVHUb3ty02ZqR7tPpfDhffhM3pOP6kZxTnzx2KfE3U1YlFe0seZAkmF5PpjOejFF\n+1O1dtSzvZpau2+6Mk/N45qvXYEm32LbSwCshoi3Vilfey6QVBsa/NFxBPMxsDpq\nrWLrkhy9m4uVA+HBeBszFqHlvMuIUktAB0xJipl3IQKBgQD91JqP3MnuVB2rLm1p\ndbf+KYTBbhtSSAX2jcz2EIPUxYpEAxZi2q1aL/UvK7O+n9iG4elcGxAnVfug+NuH\njJpl1yu+YK8g6bC6E9j9+tW9kgXqJtNlNczEBWZNJ5iKpcbcZBkVznYOiwlyCE97\nOCjVCwItDd55GrWw4Zcn99qqYQKBgQD7QBdPyR2a6zfbEWHEbsnqn6JuAgG0H952\nkfkDe1eOpPYZeYVRAycivH6u2iCCzMa2itiIRVT506qd5sK1Kikoh0HH5pqmwtFz\nxpfviWaAmPGU4GgybWuXNWHrbjYUDff2YXavenEAHMQyp6JmqEf6fk9fHZ2GovXG\nLlneLb7OfwKBgQCE+oLW55aq00qPyczsOQ3hi6LPK34Ix07IclV0fAZ0y+C57Nwn\ngeTboNBUnBKYxWlMkMIOzObTlMo09Osdwl2JCQcTv9c/6O37Lja6KFUd8YhDuX96\nQIs8DpAfz6Sszli2UYKK2BUlXVXfddcd+Lf7lL7ZF7D7xTB2sFjeSY03QQKBgFl7\nmRHccgPT1F/cT/Ky9ozub944Lr0lQIkAMizQR/3QuKmYAyg4ND8F3SSPIVcUcY1f\n5ACcmMglX7W/EweMzX3WtlHoypmr9wcB6ujwCaaxUhEQ32teVxxScd50sSPxWafR\nTIDw3cAJfsL/uzJOqtwHEmOw24KxFVGQ/obHyhYnAoGBAJi1ShoasGdYeKeukA99\n+64n9PshktE7jXIvaFxZi+P0j4qlqiQ+PZCJ9w7rht9SaYx+ZThwdvVATeGK6INR\nTFNq/HG6eVdV2WwEDL8qBN28raNKnVq9VCFjio2mvmcdK9r/kEI3F64W5ghvIiWw\n5Ulvs0fj/30VQih/i/rJ0ZKy\n-----END PRIVATE KEY-----\n", | ||
| "client_email": "firebase-adminsdk-fbsvc@techsprint-gitam.iam.gserviceaccount.com", | ||
| "client_id": "102465646528741241791", | ||
| "auth_uri": "https://accounts.google.com/o/oauth2/auth", | ||
|
Comment on lines
1
to
8
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a syntax error in the string repetition. The expression
'=' .repeat(70)has incorrect spacing - the dot should be directly attached to the string without a space. This should be'='.repeat(70).