├── backend
├── __init__.py
├── app_state
│ ├── __init__.py
│ ├── rbq.py
│ ├── db.py
│ └── redis.py
├── core
│ ├── configs
│ │ ├── __init__.py
│ │ └── coverpage.jpg
│ └── database
│ │ ├── __init__.py
│ │ ├── models
│ │ ├── base.py
│ │ ├── notification.py
│ │ ├── degree_audit.py
│ │ ├── __init__.py
│ │ ├── media.py
│ │ └── common_enums.py
│ │ └── manager.py
├── modules
│ ├── auth
│ │ ├── __init__.py
│ │ ├── dependencies.py
│ │ ├── schemas.py
│ │ └── cruds.py
│ ├── search
│ │ ├── utils.py
│ │ └── __init__.py
│ ├── bot
│ │ ├── utils
│ │ │ ├── __init__.py
│ │ │ ├── enums.py
│ │ │ ├── permissions.py
│ │ │ └── google_bucket.py
│ │ ├── filters
│ │ │ ├── __init__.py
│ │ │ └── deeplink.py
│ │ ├── locales
│ │ │ ├── en
│ │ │ │ └── LC_MESSAGES
│ │ │ │ │ └── messages.mo
│ │ │ ├── kz
│ │ │ │ └── LC_MESSAGES
│ │ │ │ │ └── messages.mo
│ │ │ └── ru
│ │ │ │ └── LC_MESSAGES
│ │ │ │ └── messages.mo
│ │ ├── routes
│ │ │ ├── __init__.py
│ │ │ └── user
│ │ │ │ └── private
│ │ │ │ ├── messages
│ │ │ │ ├── start.py
│ │ │ │ └── start_deeplink.py
│ │ │ │ ├── callback
│ │ │ │ └── confirmation.py
│ │ │ │ └── __init__.py
│ │ ├── hints_command.py
│ │ ├── README.md
│ │ ├── keyboards
│ │ │ └── callback_factory.py
│ │ ├── middlewares
│ │ │ ├── public_url.py
│ │ │ ├── redis.py
│ │ │ ├── bucket_client.py
│ │ │ ├── db_session.py
│ │ │ ├── __init__.py
│ │ │ └── i18n.py
│ │ ├── cruds.py
│ │ └── bot.py
│ ├── google_bucket
│ │ ├── cruds.py
│ │ └── __init__.py
│ ├── campuscurrent
│ │ ├── __init__.py
│ │ ├── events
│ │ │ ├── __init__.py
│ │ │ └── dependencies.py
│ │ ├── communities
│ │ │ └── __init__.py
│ │ ├── profile
│ │ │ ├── __init__.py
│ │ │ └── profile.py
│ │ └── base.py
│ ├── announcements
│ │ ├── __init__.py
│ │ ├── router.py
│ │ └── service.py
│ ├── courses
│ │ ├── planner
│ │ │ ├── __init__.py
│ │ │ └── dependencies.py
│ │ ├── registrar
│ │ │ ├── parsers
│ │ │ │ └── __init__.py
│ │ │ ├── clients
│ │ │ │ └── __init__.py
│ │ │ ├── tests
│ │ │ │ ├── test_priority_sync_worker.py
│ │ │ │ ├── test_registrar_parser.py
│ │ │ │ └── test_priority_parser.py
│ │ │ └── priority_sync_worker.py
│ │ ├── degree_audit
│ │ │ ├── requirements
│ │ │ │ └── additional_tables
│ │ │ │ │ ├── Degree audit requirments for all majors - Shortcuts.csv
│ │ │ │ │ └── Degree audit requirments for all majors - Soc_hum electives.csv
│ │ │ └── dependencies.py
│ │ ├── courses
│ │ │ ├── errors.py
│ │ │ └── base.py
│ │ └── statistics
│ │ │ └── schemas.py
│ ├── notion
│ │ ├── __init__.py
│ │ ├── utils.py
│ │ └── consts.py
│ ├── notification
│ │ ├── schemas.py
│ │ ├── notification.py
│ │ └── tasks.py
│ ├── sgotinish
│ │ ├── base.py
│ │ └── messages
│ │ │ └── dependencies.py
│ └── __init__.py
├── common
│ ├── utils
│ │ └── enums.py
│ └── schemas.py
├── start.sh
├── migrations
│ └── script.py.mako
├── Dockerfile
├── main.py
└── lifespan.py
├── infra
├── pgadmin
│ ├── pgpass
│ └── servers.json
├── rabbitmq
│ └── rabbitmq.conf
├── grafana
│ └── provisioning
│ │ ├── dashboards
│ │ └── default.yaml
│ │ ├── alerting
│ │ ├── alerting.yaml
│ │ └── contact-points.yaml.tpl
│ │ └── datasources
│ │ └── datasources.yml
├── prometheus
│ ├── grafana_alert_rules.yml
│ ├── alertmanager.yml.tpl
│ └── prometheus.yml
├── build.docker-compose.yaml
├── scripts
│ ├── start-alertmanager.sh
│ └── start-grafana.sh
└── nginx
│ └── vpn-index.html
├── ansible
├── roles
│ ├── backend
│ │ └── meta
│ │ │ └── main.yml
│ ├── frontend
│ │ └── meta
│ │ │ └── main.yml
│ ├── secrets
│ │ └── meta
│ │ │ └── main.yml
│ ├── infra_services
│ │ └── meta
│ │ │ └── main.yml
│ ├── container_analysis
│ │ └── meta
│ │ │ └── main.yml
│ ├── post_tasks
│ │ └── tasks
│ │ │ └── main.yml
│ └── repo_sync
│ │ └── tasks
│ │ └── main.yml
└── inventory.yml
├── frontend
├── src
│ ├── vite-env.d.ts
│ ├── assets
│ │ ├── favicon.png
│ │ ├── images
│ │ │ ├── miniapp.webp
│ │ │ ├── nu_logo.png
│ │ │ ├── teams
│ │ │ │ ├── adil.jpg
│ │ │ │ ├── alan.jpg
│ │ │ │ ├── ulan.jpg
│ │ │ │ ├── aisana.jpg
│ │ │ │ ├── yelnur.jpg
│ │ │ │ └── bakhtiyar.jpg
│ │ │ ├── event_pics
│ │ │ │ ├── 1.webp
│ │ │ │ ├── 2.webp
│ │ │ │ ├── 3.webp
│ │ │ │ ├── 4.webp
│ │ │ │ └── 5.webp
│ │ │ ├── google_form.png
│ │ │ ├── categories
│ │ │ │ ├── all.png
│ │ │ │ ├── food.png
│ │ │ │ ├── books.png
│ │ │ │ ├── others.png
│ │ │ │ ├── sports.png
│ │ │ │ ├── clothing.png
│ │ │ │ ├── furniture.png
│ │ │ │ ├── transport.png
│ │ │ │ ├── appliances.png
│ │ │ │ └── electronics.png
│ │ │ ├── hero_assets
│ │ │ │ ├── 1.webp
│ │ │ │ ├── 2.webp
│ │ │ │ ├── 3.webp
│ │ │ │ ├── 4.webp
│ │ │ │ └── 5.webp
│ │ │ ├── miniapp-resized.webp
│ │ │ ├── welcome-nu-space.jpg
│ │ │ └── nu-space-presentation.jpg
│ │ ├── icons
│ │ │ ├── apple-icon-180.png
│ │ │ ├── apple-splash-1136-640.jpg
│ │ │ ├── apple-splash-1334-750.jpg
│ │ │ ├── apple-splash-1792-828.jpg
│ │ │ ├── apple-splash-640-1136.jpg
│ │ │ ├── apple-splash-750-1334.jpg
│ │ │ ├── apple-splash-828-1792.jpg
│ │ │ ├── apple-splash-1125-2436.jpg
│ │ │ ├── apple-splash-1170-2532.jpg
│ │ │ ├── apple-splash-1179-2556.jpg
│ │ │ ├── apple-splash-1206-2622.jpg
│ │ │ ├── apple-splash-1242-2208.jpg
│ │ │ ├── apple-splash-1242-2688.jpg
│ │ │ ├── apple-splash-1260-2736.jpg
│ │ │ ├── apple-splash-1284-2778.jpg
│ │ │ ├── apple-splash-1290-2796.jpg
│ │ │ ├── apple-splash-1320-2868.jpg
│ │ │ ├── apple-splash-1488-2266.jpg
│ │ │ ├── apple-splash-1536-2048.jpg
│ │ │ ├── apple-splash-1620-2160.jpg
│ │ │ ├── apple-splash-1640-2360.jpg
│ │ │ ├── apple-splash-1668-2224.jpg
│ │ │ ├── apple-splash-1668-2388.jpg
│ │ │ ├── apple-splash-2048-1536.jpg
│ │ │ ├── apple-splash-2048-2732.jpg
│ │ │ ├── apple-splash-2160-1620.jpg
│ │ │ ├── apple-splash-2208-1242.jpg
│ │ │ ├── apple-splash-2224-1668.jpg
│ │ │ ├── apple-splash-2266-1488.jpg
│ │ │ ├── apple-splash-2360-1640.jpg
│ │ │ ├── apple-splash-2388-1668.jpg
│ │ │ ├── apple-splash-2436-1125.jpg
│ │ │ ├── apple-splash-2532-1170.jpg
│ │ │ ├── apple-splash-2556-1179.jpg
│ │ │ ├── apple-splash-2622-1206.jpg
│ │ │ ├── apple-splash-2688-1242.jpg
│ │ │ ├── apple-splash-2732-2048.jpg
│ │ │ ├── apple-splash-2736-1260.jpg
│ │ │ ├── apple-splash-2778-1284.jpg
│ │ │ ├── apple-splash-2796-1290.jpg
│ │ │ ├── apple-splash-2868-1320.jpg
│ │ │ ├── manifest-icon-192.maskable.png
│ │ │ └── manifest-icon-512.maskable.png
│ │ ├── svg
│ │ │ ├── telegram-connected.svg
│ │ │ └── Vector.svg
│ │ └── manifest.json
│ ├── pages
│ │ ├── profile.tsx
│ │ ├── about.tsx
│ │ ├── apps
│ │ │ ├── emergency.tsx
│ │ │ └── contacts.tsx
│ │ └── apps-layout.tsx
│ ├── utils
│ │ ├── utils.ts
│ │ ├── query-client.ts
│ │ ├── image-utils.tsx
│ │ ├── search-params.ts
│ │ └── animationVariants.ts
│ ├── components
│ │ ├── CampusLayout.tsx
│ │ ├── atoms
│ │ │ ├── spinner.tsx
│ │ │ ├── label.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── progress.tsx
│ │ │ ├── slider-button.tsx
│ │ │ ├── toggle.tsx
│ │ │ ├── input.tsx
│ │ │ ├── sonner.tsx
│ │ │ ├── tooltip.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── popover.tsx
│ │ │ └── avatar.tsx
│ │ ├── organisms
│ │ │ ├── media
│ │ │ │ └── index.ts
│ │ │ ├── about
│ │ │ │ ├── about-us-section.tsx
│ │ │ │ ├── about-header.tsx
│ │ │ │ ├── mission-section.tsx
│ │ │ │ ├── feature-card.tsx
│ │ │ │ └── report-card.tsx
│ │ │ ├── filter-container.tsx
│ │ │ ├── animations
│ │ │ │ ├── AnimatedCard.tsx
│ │ │ │ └── AnimatedFormField.tsx
│ │ │ ├── category-grid.tsx
│ │ │ ├── category-slider.tsx
│ │ │ └── admin
│ │ │ │ └── stat-card.tsx
│ │ ├── molecules
│ │ │ ├── hoc
│ │ │ │ └── with-suspense.tsx
│ │ │ ├── buttons
│ │ │ │ ├── message-button.tsx
│ │ │ │ ├── submit-button.tsx
│ │ │ │ ├── donate-button.tsx
│ │ │ │ ├── login-button.tsx
│ │ │ │ ├── channel-button.tsx
│ │ │ │ └── report-button.tsx
│ │ │ ├── QueryBoundary.tsx
│ │ │ ├── BackButton.tsx
│ │ │ ├── general-section.tsx
│ │ │ ├── telegram-status.tsx
│ │ │ ├── auth-required-alert.tsx
│ │ │ ├── theme-toggle.tsx
│ │ │ ├── DeleteConfirmation.tsx
│ │ │ ├── combined-search.tsx
│ │ │ └── login-modal.tsx
│ │ ├── ui
│ │ │ └── footer.tsx
│ │ ├── templates
│ │ │ └── about-template.tsx
│ │ ├── layout
│ │ │ ├── ProtectedRoute.tsx
│ │ │ └── PublicRoute.tsx
│ │ └── animations
│ │ │ └── AnimatedCard.tsx
│ ├── types
│ │ ├── images.d.ts
│ │ └── search.ts
│ ├── features
│ │ ├── sgotinish
│ │ │ ├── utils
│ │ │ │ ├── roleMapping.ts
│ │ │ │ └── date.ts
│ │ │ └── components
│ │ │ │ └── CreateAppealButton.tsx
│ │ ├── announcements
│ │ │ └── api
│ │ │ │ └── useTelegramPosts.ts
│ │ ├── courses
│ │ │ ├── api
│ │ │ │ └── hooks
│ │ │ │ │ ├── useGradeTerms.ts
│ │ │ │ │ └── usePreSearchGrades.ts
│ │ │ └── components
│ │ │ │ ├── forms
│ │ │ │ └── NumericInput.tsx
│ │ │ │ ├── TrendIndicator.tsx
│ │ │ │ └── live-gpa
│ │ │ │ └── SummaryCards.tsx
│ │ ├── events
│ │ │ ├── hooks
│ │ │ │ ├── useEvent.ts
│ │ │ │ ├── useVirtualEvents.ts
│ │ │ │ ├── useEvents.ts
│ │ │ │ └── useInfiniteEvents.ts
│ │ │ └── utils
│ │ │ │ ├── calendar.ts
│ │ │ │ └── eventFormatters.ts
│ │ ├── media
│ │ │ ├── types
│ │ │ │ ├── media.ts
│ │ │ │ └── types.ts
│ │ │ ├── utils
│ │ │ │ ├── get-signed-urls.ts
│ │ │ │ └── upload-media.ts
│ │ │ └── index.ts
│ │ └── communities
│ │ │ ├── hooks
│ │ │ ├── use-user-communities.ts
│ │ │ ├── useVirtualCommunities.ts
│ │ │ ├── use-search-communities.ts
│ │ │ ├── use-edit-community.ts
│ │ │ ├── useInfiniteCommunities.ts
│ │ │ ├── use-community.ts
│ │ │ ├── useDeleteCommunity.ts
│ │ │ └── use-communities.ts
│ │ │ └── api
│ │ │ └── hooks
│ │ │ └── usePreSearchCommunities.ts
│ ├── hooks
│ │ ├── useIsMacSafari.ts
│ │ ├── useFormAnimations.ts
│ │ ├── useDebounce.ts
│ │ └── useGlobalSecondTicker.ts
│ ├── layouts
│ │ ├── PublicLayout.tsx
│ │ └── LoggedInLayout.tsx
│ ├── data
│ │ ├── features.ts
│ │ └── kp
│ │ │ └── product.tsx
│ └── app
│ │ └── main.tsx
├── Dockerfile_static_builder
├── tsconfig.node.json
├── capacitor.config.ts
├── tsconfig.json
└── Dockerfile_vite
├── terraform
├── tfscheme.png
├── backend.tfbackend
├── backend.tf
├── providers.tf
├── .terraform.lock.hcl
└── envs
│ ├── production.tfvars
│ └── staging.tfvars
├── .gitmodules
├── docs
└── TODO.md
├── .pre-commit-config.yaml
├── .github
└── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
├── .gitignore
└── LICENSE
/backend/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/app_state/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/core/configs/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/core/database/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/modules/auth/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/modules/search/utils.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/modules/auth/dependencies.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/modules/bot/utils/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/modules/google_bucket/cruds.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/modules/search/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/modules/campuscurrent/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/modules/google_bucket/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/modules/campuscurrent/events/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/modules/campuscurrent/communities/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/modules/campuscurrent/profile/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/infra/pgadmin/pgpass:
--------------------------------------------------------------------------------
1 | postgres:5432:postgres:postgres:123
2 |
--------------------------------------------------------------------------------
/ansible/roles/backend/meta/main.yml:
--------------------------------------------------------------------------------
1 | ---
2 | dependencies: []
3 |
4 |
--------------------------------------------------------------------------------
/ansible/roles/frontend/meta/main.yml:
--------------------------------------------------------------------------------
1 | ---
2 | dependencies: []
3 |
4 |
--------------------------------------------------------------------------------
/ansible/roles/secrets/meta/main.yml:
--------------------------------------------------------------------------------
1 | ---
2 | dependencies: []
3 |
4 |
--------------------------------------------------------------------------------
/ansible/roles/infra_services/meta/main.yml:
--------------------------------------------------------------------------------
1 | ---
2 | dependencies: []
3 |
4 |
--------------------------------------------------------------------------------
/backend/modules/announcements/__init__.py:
--------------------------------------------------------------------------------
1 |
2 | from .router import router
3 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 |
4 |
--------------------------------------------------------------------------------
/ansible/roles/container_analysis/meta/main.yml:
--------------------------------------------------------------------------------
1 | ---
2 | dependencies: []
3 |
4 |
--------------------------------------------------------------------------------
/backend/modules/bot/filters/__init__.py:
--------------------------------------------------------------------------------
1 | from .deeplink import EncodedDeepLinkFilter
2 |
--------------------------------------------------------------------------------
/infra/rabbitmq/rabbitmq.conf:
--------------------------------------------------------------------------------
1 | log.console = true
2 | log.console.level = info
3 | log.file = false
--------------------------------------------------------------------------------
/terraform/tfscheme.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/terraform/tfscheme.png
--------------------------------------------------------------------------------
/backend/modules/courses/planner/__init__.py:
--------------------------------------------------------------------------------
1 | from .planner import router
2 |
3 | __all__ = ["router"]
4 |
5 |
--------------------------------------------------------------------------------
/frontend/src/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/favicon.png
--------------------------------------------------------------------------------
/backend/core/configs/coverpage.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/backend/core/configs/coverpage.jpg
--------------------------------------------------------------------------------
/frontend/src/pages/profile.tsx:
--------------------------------------------------------------------------------
1 | export { default } from "@/features/profile/ProfilePage";
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/terraform/backend.tfbackend:
--------------------------------------------------------------------------------
1 | bucket = "nuspace-terraform-state"
2 | credentials = "./creds/staging.json"
3 |
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/images/miniapp.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/miniapp.webp
--------------------------------------------------------------------------------
/frontend/src/assets/images/nu_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/nu_logo.png
--------------------------------------------------------------------------------
/.gitmodules:
--------------------------------------------------------------------------------
1 | [submodule "prbot"]
2 | path = backend/modules/bot/prbot
3 | url = https://github.com/mciiee/nuspace-prtelebot.git
4 |
--------------------------------------------------------------------------------
/frontend/src/assets/images/teams/adil.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/teams/adil.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/images/teams/alan.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/teams/alan.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/images/teams/ulan.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/teams/ulan.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-icon-180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-icon-180.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/event_pics/1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/event_pics/1.webp
--------------------------------------------------------------------------------
/frontend/src/assets/images/event_pics/2.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/event_pics/2.webp
--------------------------------------------------------------------------------
/frontend/src/assets/images/event_pics/3.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/event_pics/3.webp
--------------------------------------------------------------------------------
/frontend/src/assets/images/event_pics/4.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/event_pics/4.webp
--------------------------------------------------------------------------------
/frontend/src/assets/images/event_pics/5.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/event_pics/5.webp
--------------------------------------------------------------------------------
/frontend/src/assets/images/google_form.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/google_form.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/teams/aisana.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/teams/aisana.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/images/teams/yelnur.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/teams/yelnur.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/images/categories/all.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/categories/all.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/categories/food.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/categories/food.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/hero_assets/1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/hero_assets/1.webp
--------------------------------------------------------------------------------
/frontend/src/assets/images/hero_assets/2.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/hero_assets/2.webp
--------------------------------------------------------------------------------
/frontend/src/assets/images/hero_assets/3.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/hero_assets/3.webp
--------------------------------------------------------------------------------
/frontend/src/assets/images/hero_assets/4.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/hero_assets/4.webp
--------------------------------------------------------------------------------
/frontend/src/assets/images/hero_assets/5.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/hero_assets/5.webp
--------------------------------------------------------------------------------
/frontend/src/assets/images/teams/bakhtiyar.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/teams/bakhtiyar.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/images/categories/books.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/categories/books.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/categories/others.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/categories/others.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/categories/sports.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/categories/sports.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/miniapp-resized.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/miniapp-resized.webp
--------------------------------------------------------------------------------
/frontend/src/assets/images/welcome-nu-space.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/welcome-nu-space.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1136-640.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1136-640.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1334-750.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1334-750.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1792-828.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1792-828.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-640-1136.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-640-1136.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-750-1334.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-750-1334.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-828-1792.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-828-1792.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/images/categories/clothing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/categories/clothing.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/categories/furniture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/categories/furniture.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/categories/transport.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/categories/transport.png
--------------------------------------------------------------------------------
/backend/modules/bot/locales/en/LC_MESSAGES/messages.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/backend/modules/bot/locales/en/LC_MESSAGES/messages.mo
--------------------------------------------------------------------------------
/backend/modules/bot/locales/kz/LC_MESSAGES/messages.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/backend/modules/bot/locales/kz/LC_MESSAGES/messages.mo
--------------------------------------------------------------------------------
/backend/modules/bot/locales/ru/LC_MESSAGES/messages.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/backend/modules/bot/locales/ru/LC_MESSAGES/messages.mo
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1125-2436.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1125-2436.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1170-2532.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1170-2532.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1179-2556.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1179-2556.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1206-2622.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1206-2622.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1242-2208.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1242-2208.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1242-2688.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1242-2688.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1260-2736.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1260-2736.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1284-2778.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1284-2778.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1290-2796.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1290-2796.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1320-2868.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1320-2868.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1488-2266.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1488-2266.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1536-2048.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1536-2048.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1620-2160.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1620-2160.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1640-2360.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1640-2360.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1668-2224.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1668-2224.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-1668-2388.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-1668-2388.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2048-1536.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2048-1536.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2048-2732.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2048-2732.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2160-1620.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2160-1620.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2208-1242.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2208-1242.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2224-1668.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2224-1668.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2266-1488.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2266-1488.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2360-1640.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2360-1640.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2388-1668.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2388-1668.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2436-1125.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2436-1125.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2532-1170.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2532-1170.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2556-1179.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2556-1179.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2622-1206.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2622-1206.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2688-1242.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2688-1242.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2732-2048.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2732-2048.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2736-1260.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2736-1260.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2778-1284.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2778-1284.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2796-1290.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2796-1290.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/icons/apple-splash-2868-1320.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/apple-splash-2868-1320.jpg
--------------------------------------------------------------------------------
/frontend/src/assets/images/categories/appliances.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/categories/appliances.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/categories/electronics.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/categories/electronics.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/nu-space-presentation.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/images/nu-space-presentation.jpg
--------------------------------------------------------------------------------
/backend/modules/bot/utils/enums.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 |
4 | class NotificationEnum(str, Enum):
5 | ENABLE = "enable"
6 | DISABLE = "disable"
7 |
--------------------------------------------------------------------------------
/frontend/src/assets/icons/manifest-icon-192.maskable.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/manifest-icon-192.maskable.png
--------------------------------------------------------------------------------
/frontend/src/assets/icons/manifest-icon-512.maskable.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ulanpy/nuspace/HEAD/frontend/src/assets/icons/manifest-icon-512.maskable.png
--------------------------------------------------------------------------------
/backend/core/database/models/base.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy.ext.declarative import DeclarativeMeta, declarative_base
2 |
3 | Base: DeclarativeMeta = declarative_base()
4 |
--------------------------------------------------------------------------------
/terraform/backend.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | backend "gcs" {
3 | # bucket and credentials configured via -backend-config during init
4 | prefix = "infra"
5 | }
6 | }
--------------------------------------------------------------------------------
/ansible/roles/post_tasks/tasks/main.yml:
--------------------------------------------------------------------------------
1 | ---
2 | # Post-deployment tasks
3 | # DOCKER_IMAGE_TAG environment variable is no longer needed since we use 'latest' tag directly
4 |
5 |
--------------------------------------------------------------------------------
/backend/modules/courses/registrar/parsers/__init__.py:
--------------------------------------------------------------------------------
1 | """Parsers for transforming registrar payloads."""
2 |
3 | from .registrar_parser import parse_schedule # noqa: F401
4 |
5 |
--------------------------------------------------------------------------------
/docs/TODO.md:
--------------------------------------------------------------------------------
1 | # TODOs
2 | ## Current
3 | - Telegram bot for PR:
4 | Assigned: @mciiee
5 |
6 | - Backend architecture rewrite.
7 | Assigned: NO
8 |
9 | ## On hold
10 |
--------------------------------------------------------------------------------
/frontend/src/pages/about.tsx:
--------------------------------------------------------------------------------
1 | import { AboutTemplate } from "@/components/templates/about-template";
2 |
3 | export default function AboutPage() {
4 | return ;
5 | }
6 |
--------------------------------------------------------------------------------
/infra/grafana/provisioning/dashboards/default.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: 1
2 |
3 | providers:
4 | - name: Default
5 | type: file
6 | options:
7 | path: /var/lib/grafana/dashboards
8 |
--------------------------------------------------------------------------------
/backend/common/utils/enums.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 |
4 | class ResourceAction(str, Enum):
5 | CREATE = "create"
6 | READ = "read"
7 | UPDATE = "update"
8 | DELETE = "delete"
9 |
--------------------------------------------------------------------------------
/backend/modules/campuscurrent/profile/profile.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter
2 |
3 | router = APIRouter(tags=["Profile"])
4 |
5 |
6 | @router.get("/profile")
7 | async def get_profile():
8 | return
9 |
--------------------------------------------------------------------------------
/infra/grafana/provisioning/alerting/alerting.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: 1
2 |
3 | providers:
4 | - name: "default"
5 | type: file
6 | options:
7 | path: /etc/grafana/provisioning/alerting/alerts.yaml
8 |
--------------------------------------------------------------------------------
/terraform/providers.tf:
--------------------------------------------------------------------------------
1 | # providers.tf
2 |
3 | provider "google" {
4 | project = var.project_id
5 | credentials = file(var.credentials_file)
6 | region = var.region
7 | zone = var.zone
8 | }
9 |
--------------------------------------------------------------------------------
/backend/modules/courses/degree_audit/requirements/additional_tables/Degree audit requirments for all majors - Shortcuts.csv:
--------------------------------------------------------------------------------
1 | LANG
2 | GER
3 | ARB
4 | KOR
5 | POL
6 | DUT
7 | CHN
8 | FRE
9 | PER
10 | SPA
--------------------------------------------------------------------------------
/ansible/inventory.yml:
--------------------------------------------------------------------------------
1 | ---
2 | all:
3 | children:
4 | webservers:
5 | hosts:
6 | # This will be populated by the GitHub Actions workflow
7 | # with the actual host from secrets.ANSIBLE_HOST
8 |
--------------------------------------------------------------------------------
/frontend/src/utils/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/backend/modules/courses/registrar/clients/__init__.py:
--------------------------------------------------------------------------------
1 | """HTTP clients for NU registrar endpoints."""
2 |
3 | from .public_course_catalog import PublicCourseCatalogClient # noqa: F401
4 | from .registrar_client import RegistrarClient # noqa: F401
5 |
6 |
--------------------------------------------------------------------------------
/backend/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | if [ "$IS_DEBUG" = "false" ]; then
3 | gunicorn -w $(( $(nproc) * 2 + 1 )) -k uvicorn.workers.UvicornWorker -b 0.0.0.0:8000 backend.main:app;
4 | else
5 | uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload;
6 | fi
7 |
--------------------------------------------------------------------------------
/frontend/Dockerfile_static_builder:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine
2 | WORKDIR /app
3 |
4 | # Copy package files
5 | COPY frontend/package*.json ./
6 |
7 | # Install dependencies
8 | RUN npm install
9 |
10 | # Copy source code (build is done in the container command)
11 | COPY frontend .
--------------------------------------------------------------------------------
/backend/modules/courses/courses/errors.py:
--------------------------------------------------------------------------------
1 | class SemesterResolutionError(Exception):
2 | """Raised when we cannot resolve the current registrar semester."""
3 |
4 |
5 | class CourseLookupError(Exception):
6 | """Raised when a course cannot be found in registrar search."""
7 |
8 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/backend/modules/bot/routes/__init__.py:
--------------------------------------------------------------------------------
1 | from aiogram import Dispatcher
2 |
3 | from backend.modules.bot.routes.user.private import setup_private_routers
4 |
5 | def include_routers(dp: Dispatcher) -> None:
6 | private_router = setup_private_routers()
7 | dp.include_router(private_router)
8 |
--------------------------------------------------------------------------------
/frontend/src/components/CampusLayout.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from "react-router-dom";
2 |
3 | export function CampusLayout() {
4 | return (
5 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/frontend/src/components/atoms/spinner.tsx:
--------------------------------------------------------------------------------
1 | export const Spinner = () => {
2 | return (
3 |
6 | );
7 | };
8 |
--------------------------------------------------------------------------------
/infra/grafana/provisioning/datasources/datasources.yml:
--------------------------------------------------------------------------------
1 | apiVersion: 1
2 |
3 | datasources:
4 | - name: Prometheus
5 | type: prometheus
6 | url: http://prometheus:9090/prometheus/
7 | access: proxy
8 |
9 | - name: Loki
10 | type: loki
11 | url: http://loki:3100
12 | access: proxy
13 |
--------------------------------------------------------------------------------
/ansible/roles/repo_sync/tasks/main.yml:
--------------------------------------------------------------------------------
1 | ---
2 | - name: Pull latest changes from git repository
3 | ansible.builtin.git:
4 | repo: "https://github.com/ulanpy/nuspace.git"
5 | dest: "/home/{{ ansible_user }}/nuspace"
6 | version: "{{ github_ref_name | default('main') }}"
7 | force: yes
8 |
9 |
10 |
--------------------------------------------------------------------------------
/frontend/src/types/images.d.ts:
--------------------------------------------------------------------------------
1 | declare module "*.svg" {
2 | const src: string;
3 | export default src;
4 | }
5 |
6 | declare module "*.png" {
7 | const src: string;
8 | export default src;
9 | }
10 |
11 | declare module "*.jpg" {
12 | const src: string;
13 | export default src;
14 | }
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/utils/query-client.ts:
--------------------------------------------------------------------------------
1 | import { QueryClient } from "@tanstack/react-query";
2 |
3 | export const queryClient = new QueryClient({
4 | defaultOptions: {
5 | queries: {
6 | staleTime: 1000 * 60 * 10,
7 | retry: false,
8 | refetchOnWindowFocus: false,
9 | },
10 | },
11 | });
12 |
--------------------------------------------------------------------------------
/backend/modules/notion/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | Notion integration module.
3 |
4 | Provides services and background workers to sync ticket data with Notion.
5 | """
6 |
7 | from .service import NotionService
8 | from .schemas import NotionTicketMessage
9 |
10 | __all__ = ["NotionService", "NotionTicketMessage"]
11 |
12 |
13 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/astral-sh/ruff-pre-commit
3 | rev: v0.11.5 # актуальную версию можно посмотреть на GitHub
4 | hooks:
5 | - id: ruff
6 | args: [--fix]
7 |
8 | - repo: https://github.com/psf/black
9 | rev: 24.3.0
10 | hooks:
11 | - id: black
12 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/media/index.ts:
--------------------------------------------------------------------------------
1 | // Core components
2 | export { MediaPreview, MediaPreviewDefaults } from './MediaPreview';
3 | export type { MediaPreviewProps } from './MediaPreview';
4 |
5 | // Re-export commonly used types for convenience
6 | export type { MediaItem, MediaAction } from '@/features/media/types/media';
7 |
--------------------------------------------------------------------------------
/backend/modules/courses/degree_audit/dependencies.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from functools import lru_cache
4 |
5 | from backend.modules.courses.degree_audit.service import DegreeAuditService
6 |
7 |
8 | @lru_cache(maxsize=1)
9 | def get_degree_audit_service() -> DegreeAuditService:
10 | return DegreeAuditService()
11 |
12 |
--------------------------------------------------------------------------------
/frontend/capacitor.config.ts:
--------------------------------------------------------------------------------
1 | import type { CapacitorConfig } from '@capacitor/cli';
2 |
3 | const config: CapacitorConfig = {
4 | appId: 'kz.nuspace.mobile',
5 | appName: 'nuspace',
6 | webDir: 'dist',
7 | server: {
8 | url: process.env.CAPACITOR_SERVER ?? 'https://nuspace.kz',
9 | cleartext: true
10 | },
11 | };
12 |
13 | export default config;
14 |
--------------------------------------------------------------------------------
/infra/prometheus/grafana_alert_rules.yml:
--------------------------------------------------------------------------------
1 | groups:
2 | - name: GrafanaDown
3 | rules:
4 | - alert: GrafanaDown
5 | expr: up{job="grafana"} == 0
6 | for: 40m
7 | labels:
8 | severity: critical
9 | annotations:
10 | summary: "Графана не доступна"
11 | description: "@sagyzdop Графана не доступна более 40 минут"
12 |
--------------------------------------------------------------------------------
/infra/pgadmin/servers.json:
--------------------------------------------------------------------------------
1 | {
2 | "Servers": {
3 | "1": {
4 | "Name": "PostgreSQL",
5 | "Group": "Servers",
6 | "Host": "postgres",
7 | "Port": 5432,
8 | "MaintenanceDB": "postgres",
9 | "Username": "postgres",
10 | "PassFile": "/var/lib/pgadmin/pgpass",
11 | "SSLMode": "prefer",
12 | "ConnectNow": true
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/src/features/sgotinish/utils/roleMapping.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Maps backend role names to display names
3 | */
4 | export const mapRoleToDisplayName = (role: string): string => {
5 | switch (role) {
6 | case "boss":
7 | return "head";
8 | case "capo":
9 | return "executive";
10 | case "soldier":
11 | return "member";
12 | default:
13 | return role;
14 | }
15 | };
16 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/hoc/with-suspense.tsx:
--------------------------------------------------------------------------------
1 | import { Spinner } from "@/components/atoms/spinner";
2 | import { ComponentType, Suspense } from "react";
3 |
4 | export const withSuspense =
5 | (Component: ComponentType
) =>
6 | (props: P) => {
7 | return (
8 | }>
9 |
10 |
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/backend/modules/bot/hints_command.py:
--------------------------------------------------------------------------------
1 | from aiogram import Bot
2 | from aiogram.types import BotCommand, BotCommandScopeAllPrivateChats
3 |
4 |
5 | async def set_commands(bot: Bot):
6 | start = BotCommand(command="start", description="🟢 Start")
7 | language = BotCommand(command="language", description="🟢 Change language")
8 | await bot.set_my_commands(commands=[start, language], scope=BotCommandScopeAllPrivateChats())
9 |
--------------------------------------------------------------------------------
/backend/modules/bot/README.md:
--------------------------------------------------------------------------------
1 | ### Telegram Bot Localization binary compilation
2 |
3 | ```bash
4 | msgfmt backend/routes/bot/locales/ru/LC_MESSAGES/messages.po -o backend/routes/bot/locales/ru/LC_MESSAGES/messages.mo
5 | msgfmt backend/routes/bot/locales/en/LC_MESSAGES/messages.po -o backend/routes/bot/locales/en/LC_MESSAGES/messages.mo
6 | msgfmt backend/routes/bot/locales/kz/LC_MESSAGES/messages.po -o backend/routes/bot/locales/kz/LC_MESSAGES/messages.mo
7 | ```
--------------------------------------------------------------------------------
/frontend/src/components/organisms/about/about-us-section.tsx:
--------------------------------------------------------------------------------
1 | import { ReportCard } from "@/components/organisms/about/report-card";
2 | import { TeamCard } from "@/components/organisms/about/team-card";
3 |
4 | export function AboutUsSection() {
5 | return (
6 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/infra/grafana/provisioning/alerting/contact-points.yaml.tpl:
--------------------------------------------------------------------------------
1 | apiVersion: 1
2 |
3 | contactPoints:
4 | - orgId: 1
5 | name: Telegram
6 | receivers:
7 | - uid: telegram
8 | type: telegram
9 | disableResolveMessage: false
10 | allowedit: true
11 | settings:
12 | bottoken: '${TELEGRAM_BOT_TOKEN}'
13 | chatid: '${TELEGRAM_CHAT_ID}'
14 | message: |
15 | {{ template "default.message" . }}
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: "[BUG]"
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Bug Description**
11 | Clear description of the issue.
12 |
13 | **Steps to Reproduce**
14 | 1.
15 |
16 | **Expected Behavior**
17 | What should happen instead.
18 |
19 | **Screenshots (optional)**
20 | Add if helpful.
21 |
22 | **Environment (optional)**
23 | - OS:
24 | - Browser/App:
25 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/footer.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import { ROUTES } from "@/data/routes";
3 |
4 | interface FooterProps {
5 | note: string;
6 | }
7 |
8 | export function Footer({ note }: FooterProps) {
9 | return (
10 |
11 |
12 | {note}
13 |
14 |
15 | );
16 | }
--------------------------------------------------------------------------------
/infra/prometheus/alertmanager.yml.tpl:
--------------------------------------------------------------------------------
1 | global:
2 | resolve_timeout: 5m
3 |
4 | route:
5 | receiver: 'telegram'
6 |
7 | receivers:
8 | - name: 'telegram'
9 | telegram_configs:
10 | - bot_token: '${TELEGRAM_BOT_TOKEN}'
11 | chat_id: ${TELEGRAM_CHAT_ID}
12 | message: |
13 | [{{ .Status | toUpper }}] {{ .CommonAnnotations.summary }}
14 | {{ range .Alerts }}
15 | Description: {{ .Annotations.description }}
16 | {{ end }}
--------------------------------------------------------------------------------
/frontend/src/features/announcements/api/useTelegramPosts.ts:
--------------------------------------------------------------------------------
1 |
2 | import { useQuery } from "@tanstack/react-query";
3 | import { apiCall } from "@/utils/api";
4 |
5 | export const useTelegramPosts = () => {
6 | return useQuery({
7 | queryKey: ["announcements", "telegram"],
8 | queryFn: async () => {
9 | const res = await apiCall<{ latest_post_id: number }>("/announcements/telegram");
10 | return res.latest_post_id;
11 | }
12 | });
13 | };
14 |
--------------------------------------------------------------------------------
/backend/modules/bot/keyboards/callback_factory.py:
--------------------------------------------------------------------------------
1 | from aiogram.filters.callback_data import CallbackData
2 | from backend.modules.bot.utils.enums import NotificationEnum
3 |
4 |
5 | class ConfirmTelegramUser(CallbackData, prefix="confirm"):
6 | sub: str
7 | number: int
8 | confirmation_number: int
9 |
10 |
11 | class Languages(CallbackData, prefix="language"):
12 | language: str
13 |
14 |
15 | class NotificationAction(CallbackData, prefix="notif"):
16 | action: NotificationEnum
17 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useIsMacSafari.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export function useIsMacSafari() {
4 | const [isMacSafari, setIsMacSafari] = useState(false);
5 |
6 | useEffect(() => {
7 | if (typeof navigator === "undefined") return;
8 | const ua = navigator.userAgent;
9 | const detected =
10 | /Macintosh/.test(ua) && /Safari/.test(ua) && !/(Chrome|Chromium|Edg)/.test(ua);
11 | setIsMacSafari(detected);
12 | }, []);
13 |
14 | return isMacSafari;
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/utils/image-utils.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Gets a placeholder image URL with cache busting
3 | * @param width Width of the image
4 | * @param height Height of the image
5 | * @returns A placeholder image URL
6 | */
7 | export function getPlaceholderImage(width = 200, height = 200): string {
8 | // Add a timestamp to prevent caching
9 | const timestamp = new Date().getTime();
10 |
11 | // Universal placeholder for all products
12 | return `https://placehold.co/${width}x${height}/EEE/31343C?text=No+Image&_t=${timestamp}`;
13 | }
14 |
--------------------------------------------------------------------------------
/infra/build.docker-compose.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | fastapi:
3 | image: kamikadze24/fastapi:${TAG}
4 |
5 | build:
6 | context: ..
7 | dockerfile: backend/Dockerfile
8 | args:
9 | IS_DEBUG: ${IS_DEBUG:-true} # Default to 'true' if not set
10 |
11 | frontend-builder:
12 | image: kamikadze24/frontendbuilder:${TAG}
13 | build:
14 | context: ..
15 | dockerfile: frontend/Dockerfile_static_builder
16 | command: sh -c "npm run build" # Run build command on container start
17 | restart: "no"
18 |
--------------------------------------------------------------------------------
/backend/modules/announcements/router.py:
--------------------------------------------------------------------------------
1 |
2 | from fastapi import APIRouter, HTTPException
3 | from backend.modules.announcements.service import get_latest_telegram_post_id
4 |
5 | router = APIRouter(
6 | prefix="/announcements",
7 | tags=["announcements"],
8 | )
9 |
10 | @router.get("/telegram")
11 | async def get_announcements_from_telegram():
12 | """
13 | Get latest announcements from the public Telegram channel.
14 | """
15 | latest_id = await get_latest_telegram_post_id()
16 | return {"latest_post_id": latest_id}
17 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/buttons/message-button.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/atoms/button";
2 | import { MessageSquare } from "lucide-react";
3 |
4 | export function MessageButton() {
5 | return (
6 |
7 |
12 |
13 | Message
14 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/frontend/src/features/courses/api/hooks/useGradeTerms.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { apiCall } from "@/utils/api";
3 | import { GradeTermsResponse } from "../../types";
4 |
5 | export const useGradeTerms = () => {
6 | const { data, isLoading } = useQuery({
7 | queryKey: ["grade-terms"],
8 | queryFn: async ({ signal }) => {
9 | return apiCall("/grades/terms", { signal });
10 | },
11 | });
12 |
13 | return {
14 | terms: data?.terms ?? [],
15 | isLoading,
16 | };
17 | };
18 |
19 |
--------------------------------------------------------------------------------
/frontend/src/types/search.ts:
--------------------------------------------------------------------------------
1 | export interface PreSearchedItem {
2 | id: number | string;
3 | name: string;
4 | }
5 |
6 | export type SearchInputProps = {
7 | inputValue: string;
8 | setInputValue: (value: string) => void;
9 | preSearchedItems: PreSearchedItem[] | null;
10 | handleSearch: (inputValue: string) => void;
11 | setKeyword: (keyword: string) => void;
12 | // Optional, used in some contexts (e.g., products) to reset a secondary filter
13 | setSelectedCondition?: (condition: string) => void;
14 | placeholder?: string;
15 | };
16 |
17 |
18 |
--------------------------------------------------------------------------------
/frontend/src/features/events/hooks/useEvent.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "../api/eventsApi";
3 | import { useParams } from "react-router-dom";
4 |
5 | export const useEvent = () => {
6 | const { id } = useParams<{ id: string }>();
7 | const {
8 | data: event,
9 | isPending,
10 | isLoading,
11 | isError,
12 | } = useQuery({
13 | ...campuscurrentAPI.getEventQueryOptions(id || ""),
14 | enabled: !!id,
15 | });
16 | return { event: event || null, isPending, isLoading, isError };
17 | };
18 |
--------------------------------------------------------------------------------
/frontend/src/features/media/types/media.ts:
--------------------------------------------------------------------------------
1 | // src/features/media/types/media.ts
2 |
3 | export interface MediaItem {
4 | id?: number | string;
5 | url: string;
6 | name?: string;
7 | size?: number;
8 | isMain?: boolean;
9 | type?: 'image' | 'video' | 'document';
10 | }
11 |
12 | export interface MediaAction {
13 | id: string;
14 | label: string;
15 | icon: React.ComponentType<{ className?: string }>;
16 | onClick: (index: number, item: MediaItem) => void;
17 | variant?: 'default' | 'destructive';
18 | showInHover?: boolean;
19 | showInDropdown?: boolean;
20 | }
21 |
22 |
--------------------------------------------------------------------------------
/frontend/src/components/templates/about-template.tsx:
--------------------------------------------------------------------------------
1 | import { AboutHeader } from "@/components/organisms/about/about-header";
2 | import { MessionSection } from "@/components/organisms/about/mission-section";
3 | import { AboutUsSection } from "@/components/organisms/about/about-us-section";
4 |
5 | export function AboutTemplate() {
6 | return (
7 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/backend/app_state/rbq.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 | from faststream.rabbit import RabbitBroker
3 |
4 | from backend.modules.notification import tasks
5 | from backend.modules.notion import tasks as notion_tasks # noqa: F401 (register subscribers)
6 |
7 |
8 | async def setup_rbq(app: FastAPI):
9 | broker: RabbitBroker = tasks.broker
10 | await broker.connect()
11 | await broker.start()
12 |
13 | app.state.broker = broker
14 |
15 |
16 | async def cleanup_rbq(app: FastAPI):
17 | broker: RabbitBroker = tasks.broker
18 | await broker.stop()
19 | app.state.broker = None
20 |
--------------------------------------------------------------------------------
/backend/modules/bot/middlewares/public_url.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Awaitable, Callable
2 |
3 | from aiogram import BaseMiddleware
4 | from aiogram.types import TelegramObject
5 |
6 |
7 | class UrlMiddleware(BaseMiddleware):
8 | def __init__(self, url: str) -> None:
9 | self.url = url
10 |
11 | async def __call__(
12 | self,
13 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
14 | event: TelegramObject,
15 | data: dict[str, Any],
16 | ) -> Any:
17 | data["public_url"] = self.url
18 | return await handler(event, data)
19 |
--------------------------------------------------------------------------------
/frontend/src/features/communities/hooks/use-user-communities.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "../api/communitiesApi";
3 |
4 | export const useUserCommunities = (userSub: string | null | undefined) => {
5 | const { data, isLoading, isError } = useQuery({
6 | ...campuscurrentAPI.getUserCommunitiesQueryOptions(userSub || ""),
7 | enabled: !!userSub,
8 | });
9 |
10 | return {
11 | communities: (data as any)?.communities || [],
12 | isLoading,
13 | isError,
14 | totalCommunities: (data as any)?.total_pages || 0,
15 | };
16 | };
17 |
--------------------------------------------------------------------------------
/backend/modules/auth/schemas.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Dict
2 |
3 | from pydantic import BaseModel, EmailStr
4 |
5 | from backend.core.database.models.user import UserRole, UserScope
6 |
7 |
8 | class UserSchema(BaseModel):
9 | email: EmailStr
10 | role: UserRole
11 | scope: UserScope
12 | name: str
13 | surname: str
14 | picture: str
15 | sub: str
16 |
17 |
18 | class Sub(BaseModel):
19 | sub: str
20 |
21 |
22 | class CurrentUserResponse(BaseModel):
23 | user: Dict[str, Any] # This will store user token data
24 | tg_id: int | None = None # Indicates if user exists in the database
25 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useFormAnimations.ts:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | export function useFormAnimations() {
4 | const [focusedField, setFocusedField] = useState(null);
5 |
6 | const handleFieldFocus = (fieldName: string) => {
7 | setFocusedField(fieldName);
8 | };
9 |
10 | const handleFieldBlur = () => {
11 | setFocusedField(null);
12 | };
13 |
14 | const isFieldFocused = (fieldName: string) => {
15 | return focusedField === fieldName;
16 | };
17 |
18 | return {
19 | focusedField,
20 | handleFieldFocus,
21 | handleFieldBlur,
22 | isFieldFocused,
23 | };
24 | }
--------------------------------------------------------------------------------
/frontend/src/features/events/hooks/useVirtualEvents.ts:
--------------------------------------------------------------------------------
1 | import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
2 | import { Event } from '@/features/events/types';
3 | import * as Routes from '@/data/routes';
4 |
5 | export function useVirtualEvents(keyword: string = "") {
6 | return useInfiniteScroll({
7 | queryKey: ["campusCurrent", "events"],
8 | apiEndpoint: `/${Routes.EVENTS}`,
9 | size: 12,
10 | keyword,
11 | additionalParams: {},
12 | estimateSize: () => 250, // Estimate each event card to be 250px tall
13 | overscan: 4, // Only render 4 items outside viewport
14 | });
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/layouts/PublicLayout.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Outlet } from "react-router-dom";
4 | import { IconThemeToggle } from "@/components/atoms/icon-theme-toggle";
5 |
6 | export function PublicLayout() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | );
17 | }
18 |
19 | export default PublicLayout;
20 |
--------------------------------------------------------------------------------
/backend/modules/bot/middlewares/redis.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Awaitable, Callable
2 |
3 | from aiogram import BaseMiddleware
4 | from aiogram.types import TelegramObject
5 | from redis.asyncio import Redis
6 |
7 |
8 | class RedisMiddleware(BaseMiddleware):
9 | def __init__(self, redis: Redis) -> None:
10 | self.redis = redis
11 |
12 | async def __call__(
13 | self,
14 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
15 | event: TelegramObject,
16 | data: dict[str, Any],
17 | ) -> Any:
18 | data["redis"] = self.redis
19 | return await handler(event, data)
20 |
--------------------------------------------------------------------------------
/backend/modules/bot/routes/user/private/messages/start.py:
--------------------------------------------------------------------------------
1 | from typing import Callable
2 |
3 | from aiogram import Router
4 | from aiogram.filters import CommandStart
5 | from aiogram.types import Message
6 | from redis.asyncio import Redis
7 |
8 | from backend.modules.bot.keyboards.kb import kb_languages, kb_url
9 |
10 | router = Router()
11 |
12 |
13 | @router.message(CommandStart(deep_link=False))
14 | async def user_start(m: Message, public_url: str, _: Callable[[str], str], redis: Redis):
15 | await m.answer(
16 | _("Добро пожаловать в NUspace, перейди по ссылке ниже!"),
17 | reply_markup=kb_url(url=public_url),
18 | )
19 |
--------------------------------------------------------------------------------
/infra/prometheus/prometheus.yml:
--------------------------------------------------------------------------------
1 | global:
2 | scrape_interval: 15s
3 |
4 | scrape_configs:
5 | - job_name: grafana
6 | honor_timestamps: true
7 | scrape_interval: 15s
8 | scrape_timeout: 10s
9 | metrics_path: /metrics
10 | scheme: http
11 | follow_redirects: true
12 | static_configs:
13 | - targets:
14 | - grafana:3000
15 | metric_relabel_configs:
16 | - source_labels: [__name__]
17 | action: keep
18 | regex: "(up)"
19 |
20 | alerting:
21 | alertmanagers:
22 | - static_configs:
23 | - targets: ["alertmanager:9093"]
24 |
25 | rule_files:
26 | - "grafana_alert_rules.yml"
27 |
--------------------------------------------------------------------------------
/frontend/src/features/communities/hooks/useVirtualCommunities.ts:
--------------------------------------------------------------------------------
1 | import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
2 | import { Community } from '@/features/communities/types';
3 | import * as Routes from '@/data/routes';
4 |
5 | export function useVirtualCommunities(keyword: string = "") {
6 | return useInfiniteScroll({
7 | queryKey: ["campusCurrent", "communities"],
8 | apiEndpoint: `/${Routes.COMMUNITIES}`,
9 | size: 12,
10 | keyword,
11 | additionalParams: {},
12 | estimateSize: () => 200, // Estimate each community card to be 200px tall
13 | overscan: 4, // Only render 4 items outside viewport
14 | });
15 | }
16 |
17 |
--------------------------------------------------------------------------------
/frontend/src/features/sgotinish/components/CreateAppealButton.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/atoms/button";
2 | import { FileText } from "lucide-react";
3 |
4 | interface CreateAppealButtonProps {
5 | onClick: () => void;
6 | }
7 |
8 | export function CreateAppealButton({ onClick }: CreateAppealButtonProps) {
9 | return (
10 |
14 |
15 | Create Appeal
16 | Create
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/backend/app_state/db.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 |
3 | from backend.core.configs.config import config
4 | from backend.core.database.manager import AsyncDatabaseManager
5 |
6 |
7 | async def setup_db(app: FastAPI):
8 | app.state.db_manager = AsyncDatabaseManager()
9 | # Avoid implicit schema creation in production – rely on Alembic migrations instead
10 |
11 | # === When modifying tables, comment this out! ===
12 | if config.IS_DEBUG:
13 | await app.state.db_manager.create_all_tables()
14 |
15 |
16 | async def cleanup_db(app: FastAPI):
17 | db_manager = getattr(app.state, "db_manager", None)
18 | if db_manager:
19 | await db_manager.async_engine.dispose()
20 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/about/about-header.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "@/context/ThemeProviderContext";
2 |
3 | export const AboutHeader = () => {
4 | const { theme } = useTheme();
5 | const isDark = theme === "dark";
6 | return (
7 |
8 |
9 | About Nuspace
10 |
11 |
16 | SuperApp for Nazarbayev University students
17 |
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useDebounce.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 |
3 | export function useDebounce(value: T, delay: number): T {
4 | const [debouncedValue, setDebouncedValue] = useState(value);
5 | const isFirstRender = useRef(true);
6 |
7 | useEffect(() => {
8 | if (isFirstRender.current) {
9 | isFirstRender.current = false;
10 | setDebouncedValue(value);
11 | return;
12 | }
13 |
14 | const timer = window.setTimeout(() => {
15 | setDebouncedValue(value);
16 | }, delay);
17 |
18 | return () => {
19 | window.clearTimeout(timer);
20 | };
21 | }, [value, delay]);
22 |
23 | return debouncedValue;
24 | }
25 |
26 |
--------------------------------------------------------------------------------
/frontend/src/pages/apps/emergency.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ContactsInfoSection } from "@/components/organisms/contacts-info-section";
4 |
5 | export default function ContactsPage() {
6 | return (
7 |
8 |
9 |
Contacts & Essential Services
10 |
11 | Save these contacts. In an emergency, call campus security or local services immediately.
12 |
13 |
14 |
15 |
16 | );
17 | }
18 |
19 |
20 |
--------------------------------------------------------------------------------
/frontend/src/features/sgotinish/utils/date.ts:
--------------------------------------------------------------------------------
1 | const ensureUtcString = (value: string): string => {
2 | // If the string already has timezone info (Z or +/-HH:MM), keep it
3 | if (/([zZ]|[+-]\d{2}:?\d{2})$/.test(value)) {
4 | return value;
5 | }
6 |
7 | // Otherwise assume backend sent UTC without suffix, so append Z
8 | return `${value}Z`;
9 | };
10 |
11 | export const toLocalDate = (value: Date | string): Date => {
12 | if (value instanceof Date) {
13 | return value;
14 | }
15 |
16 | return new Date(ensureUtcString(value));
17 | };
18 |
19 | export const toLocalISOString = (value: Date | string): string => {
20 | return toLocalDate(value).toLocaleString();
21 | };
22 |
23 |
24 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/backend/app_state/redis.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 | from redis.asyncio import ConnectionPool, Redis
3 |
4 | from backend.core.configs.config import config
5 |
6 |
7 | async def setup_redis(app: FastAPI):
8 | redis_pool = ConnectionPool.from_url(
9 | config.REDIS_URL,
10 | max_connections=50,
11 | socket_connect_timeout=5,
12 | socket_timeout=10,
13 | health_check_interval=30,
14 | retry_on_timeout=True,
15 | decode_responses=True,
16 | )
17 | app.state.redis = Redis(connection_pool=redis_pool)
18 |
19 |
20 | async def cleanup_redis(app: FastAPI):
21 | if redis := getattr(app.state, "redis", None):
22 | await redis.aclose()
23 |
--------------------------------------------------------------------------------
/frontend/src/assets/svg/telegram-connected.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/backend/modules/bot/middlewares/bucket_client.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Awaitable, Callable
2 |
3 | from aiogram import BaseMiddleware
4 | from aiogram.types import TelegramObject
5 | from google.cloud import storage
6 |
7 |
8 | class BucketClientMiddleware(BaseMiddleware):
9 | def __init__(self, storage_client: storage.Client) -> None:
10 | self.storage_client = storage_client
11 |
12 | async def __call__(
13 | self,
14 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
15 | event: TelegramObject,
16 | data: dict[str, Any],
17 | ) -> Any:
18 | data["storage_client"] = self.storage_client
19 | return await handler(event, data)
20 |
--------------------------------------------------------------------------------
/frontend/src/features/events/utils/calendar.ts:
--------------------------------------------------------------------------------
1 | import { Event } from "../../types/types";
2 |
3 | export const addToGoogleCalendar = (event: Event) => {
4 | const eventDate = new Date(event.start_datetime);
5 | const endDate = new Date(event.end_datetime);
6 |
7 | const googleCalendarUrl = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(
8 | event.name,
9 | )}&dates=${eventDate
10 | .toISOString()
11 | .replace(/-|:|\.\d+/g, "")}/${endDate
12 | .toISOString()
13 | .replace(/-|:|\.\d+/g, "")}&details=${encodeURIComponent(
14 | event.description,
15 | )}&location=${encodeURIComponent(event.place)}`;
16 |
17 | window.open(googleCalendarUrl, "_blank");
18 | };
19 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/filter-container.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | interface FilterContainerProps {
4 | children: React.ReactNode;
5 | className?: string;
6 | title?: string;
7 | }
8 |
9 | export function FilterContainer({ children, className = "", title }: FilterContainerProps) {
10 | return (
11 |
20 | {title && (
21 |
22 | {title}
23 |
24 | )}
25 | {children}
26 |
27 | );
28 | }
--------------------------------------------------------------------------------
/backend/modules/bot/utils/permissions.py:
--------------------------------------------------------------------------------
1 | from aiogram.types import ChatPermissions
2 |
3 | no_permissions = ChatPermissions(
4 | can_send_messages=False,
5 | can_send_audios=False,
6 | can_send_documents=False,
7 | can_send_photos=False,
8 | can_send_videos=False,
9 | can_send_video_notes=False,
10 | can_send_voice_notes=False,
11 | can_send_polls=False,
12 | can_invite_users=False,
13 | )
14 |
15 | all_permissions = ChatPermissions(
16 | can_send_messages=True,
17 | can_send_audios=True,
18 | can_send_documents=True,
19 | can_send_photos=True,
20 | can_send_videos=True,
21 | can_send_video_notes=True,
22 | can_send_voice_notes=True,
23 | can_send_polls=True,
24 | can_invite_users=True,
25 | )
26 |
--------------------------------------------------------------------------------
/backend/modules/bot/filters/deeplink.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from aiogram.filters import BaseFilter, CommandObject
4 | from aiogram.types import Message
5 | from aiogram.utils.payload import decode_payload
6 |
7 |
8 | class EncodedDeepLinkFilter(BaseFilter):
9 | def __init__(self, prefix: Optional[str] = None):
10 | self.prefix = prefix
11 |
12 | async def __call__(self, message: Message, command: CommandObject) -> bool:
13 | args = command.args
14 | payload: str = decode_payload(args)
15 |
16 | if not payload:
17 | return False
18 |
19 | if self.prefix:
20 | if payload.startswith(self.prefix):
21 | return True
22 | return False
23 | return False
24 |
--------------------------------------------------------------------------------
/frontend/src/data/features.ts:
--------------------------------------------------------------------------------
1 | import { MdEvent, MdRestaurantMenu } from "react-icons/md";
2 | import { ROUTES } from "./routes";
3 | export const features = [
4 | {
5 | title: "Events",
6 | description:
7 | "Information about holidays, meetings and events that take place on the territory of the University. Students will be able to find activities that are interesting to them.",
8 | icon: MdEvent,
9 | link: ROUTES.EVENTS.ROOT,
10 | },
11 | {
12 | title: "Dorm Eats",
13 | description:
14 | "Daily menu in the university canteen. What dishes are available, what dishes are being prepared - all this students have the opportunity to find out in advance.",
15 | icon: MdRestaurantMenu,
16 | link: ROUTES.DORM_EATS,
17 | },
18 | ];
19 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/QueryBoundary.tsx:
--------------------------------------------------------------------------------
1 | import type { ReactNode } from "react";
2 |
3 | export type QueryBoundaryProps = {
4 | data: TData | null | undefined;
5 | isLoading?: boolean;
6 | isError?: boolean;
7 | loadingFallback?: ReactNode;
8 | errorFallback?: ReactNode;
9 | children: (data: TData) => ReactNode;
10 | };
11 |
12 | export function QueryBoundary({
13 | data,
14 | isLoading = false,
15 | isError = false,
16 | loadingFallback = null,
17 | errorFallback = null,
18 | children,
19 | }: QueryBoundaryProps) {
20 | if (isLoading) {
21 | return <>{loadingFallback}>;
22 | }
23 |
24 | if (isError || data == null) {
25 | return <>{errorFallback}>;
26 | }
27 |
28 | return <>{children(data)}>;
29 | }
30 |
31 |
32 |
--------------------------------------------------------------------------------
/frontend/src/features/media/utils/get-signed-urls.ts:
--------------------------------------------------------------------------------
1 | import { UploadMediaOptions } from "../types/types";
2 | import { SignedUrlRequest, SignedUrlResponse } from "../types/types";
3 | import { mediaApi } from "../api/mediaApi";
4 |
5 | export const getSignedUrls = async (
6 | entityId: number,
7 | files: File[],
8 | options: Omit,
9 | ): Promise => {
10 | const requests: SignedUrlRequest[] = files.map((file, idx) => ({
11 | entity_type: options.entity_type,
12 | entity_id: entityId,
13 | media_format: options.mediaFormat,
14 | media_order: (options.startOrder || 0) + idx,
15 | mime_type: file.type,
16 | content_type: file.type,
17 | }));
18 |
19 | return await mediaApi.getSignedUrls(requests);
20 | };
--------------------------------------------------------------------------------
/backend/migrations/script.py.mako:
--------------------------------------------------------------------------------
1 | """${message}
2 |
3 | Revision ID: ${up_revision}
4 | Revises: ${down_revision | comma,n}
5 | Create Date: ${create_date}
6 |
7 | """
8 | from typing import Sequence, Union
9 |
10 | from alembic import op
11 | import sqlalchemy as sa
12 | ${imports if imports else ""}
13 |
14 | # revision identifiers, used by Alembic.
15 | revision: str = ${repr(up_revision)}
16 | down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19 |
20 |
21 | def upgrade() -> None:
22 | """Upgrade schema."""
23 | ${upgrades if upgrades else "pass"}
24 |
25 |
26 | def downgrade() -> None:
27 | """Downgrade schema."""
28 | ${downgrades if downgrades else "pass"}
29 |
--------------------------------------------------------------------------------
/frontend/src/features/communities/hooks/use-search-communities.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "../api/communitiesApi";
3 | import { Community } from "@/features/shared/campus/types";
4 |
5 | export const useSearchCommunities = (params?: { keyword?: string; size?: number }) => {
6 | const keyword = (params?.keyword ?? "").trim();
7 | const size = params?.size ?? (keyword ? 10 : 20);
8 |
9 | const { data, isLoading, isError } = useQuery>(
10 | campuscurrentAPI.getCommunitiesQueryOptions({
11 | page: 1,
12 | size,
13 | keyword: keyword || null,
14 | category: null,
15 | })
16 | );
17 |
18 | return {
19 | communities: data || null,
20 | isLoading,
21 | isError,
22 | };
23 | };
24 |
25 |
26 |
--------------------------------------------------------------------------------
/frontend/src/pages/apps/contacts.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ContactsInfoSection } from "@/components/organisms/contacts-info-section";
4 | import MotionWrapper from "@/components/atoms/motion-wrapper";
5 |
6 | export default function ContactsPage() {
7 | return (
8 |
9 |
10 |
11 |
12 | Contacts & Essential Services
13 |
14 |
15 | Save these contacts. In an emergency, call campus security or local services immediately.
16 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
24 |
25 |
--------------------------------------------------------------------------------
/frontend/src/components/atoms/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as LabelPrimitive from "@radix-ui/react-label";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/utils/utils";
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
9 | );
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ));
22 | Label.displayName = LabelPrimitive.Root.displayName;
23 |
24 | export { Label };
25 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 |
23 | /* Paths */
24 | "baseUrl": ".",
25 | "paths": {
26 | "@/*": ["./src/*"]
27 | }
28 | },
29 | "include": ["src", "src/types"],
30 | "references": [{ "path": "./tsconfig.node.json" }]
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/src/features/courses/components/forms/NumericInput.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from "react";
2 | import { Input, type InputProps } from "@/components/atoms/input";
3 | import { useIsMacSafari } from "@/hooks/useIsMacSafari";
4 |
5 | interface NumericInputProps extends InputProps {
6 | allowDecimal?: boolean;
7 | }
8 |
9 | export const NumericInput = forwardRef(
10 | ({ allowDecimal = true, inputMode, ...props }, ref) => {
11 | const isMacSafari = useIsMacSafari();
12 | const resolvedInputMode = inputMode ?? (allowDecimal ? "decimal" : "numeric");
13 |
14 | return (
15 |
20 | );
21 | },
22 | );
23 |
24 | NumericInput.displayName = "NumericInput";
25 |
26 |
--------------------------------------------------------------------------------
/backend/modules/bot/middlewares/db_session.py:
--------------------------------------------------------------------------------
1 | import contextlib
2 | from typing import Any, Awaitable, Callable
3 |
4 | from aiogram import BaseMiddleware
5 | from aiogram.types import TelegramObject
6 |
7 | from backend.core.database.manager import AsyncDatabaseManager
8 |
9 |
10 | class DatabaseMiddleware(BaseMiddleware):
11 | def __init__(self, db_manager: AsyncDatabaseManager) -> None:
12 | self.db_manager = db_manager
13 |
14 | async def __call__(
15 | self,
16 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
17 | event: TelegramObject,
18 | data: dict[str, Any],
19 | ) -> Any:
20 |
21 | async with contextlib.asynccontextmanager(self.db_manager.get_async_session)() as session:
22 | data["db_session"] = session
23 | return await handler(event, data)
24 |
--------------------------------------------------------------------------------
/backend/modules/courses/courses/base.py:
--------------------------------------------------------------------------------
1 | from fastapi import HTTPException
2 | from fastapi import status as http_status
3 |
4 | from backend.core.database.models.user import UserRole
5 |
6 |
7 | class BasePolicy:
8 | """Base policy with common user attributes."""
9 |
10 | def __init__(self, user: tuple[dict, dict]):
11 | if not user or not user[0] or not user[1]:
12 | raise HTTPException(
13 | status_code=http_status.HTTP_401_UNAUTHORIZED,
14 | detail="Authentication credentials were not provided",
15 | )
16 | self.user_creds = user
17 | self.user_role = user[1]["role"]
18 | self.user_sub = user[0]["sub"]
19 | self.is_admin = self.user_role == UserRole.admin.value
20 |
21 | def _is_owner(self, author_sub: str) -> bool:
22 | return self.user_sub == author_sub
23 |
--------------------------------------------------------------------------------
/backend/modules/courses/planner/dependencies.py:
--------------------------------------------------------------------------------
1 | from fastapi import Depends
2 | from sqlalchemy.ext.asyncio import AsyncSession
3 |
4 | from backend.common.dependencies import get_db_session, get_infra
5 | from backend.common.schemas import Infra
6 | from backend.modules.courses.planner.repository import PlannerRepository
7 | from backend.modules.courses.planner.service import PlannerService
8 | from backend.modules.courses.registrar.service import RegistrarService
9 |
10 |
11 | async def get_planner_service(
12 | db_session: AsyncSession = Depends(get_db_session),
13 | infra: Infra = Depends(get_infra),
14 | ) -> PlannerService:
15 | repository = PlannerRepository(db_session)
16 | registrar_service = RegistrarService(meilisearch_client=infra.meilisearch_client)
17 | return PlannerService(repository=repository, registrar_service=registrar_service)
18 |
19 |
--------------------------------------------------------------------------------
/backend/modules/notion/utils.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from typing import Iterable, Union
4 |
5 | from backend.common.schemas import Infra
6 | from backend.modules.notion import schemas
7 | from backend.modules.notion.consts import NOTION_QUEUE_NAME
8 |
9 |
10 |
11 | async def send(
12 | *,
13 | infra: Infra,
14 | notion_data: Union[schemas.NotionTicketMessage, Iterable[schemas.NotionTicketMessage]],
15 | ) -> list[schemas.NotionTicketMessage]:
16 | """
17 | Publish ticket snapshot(s) to the Notion queue.
18 | """
19 | if isinstance(notion_data, schemas.NotionTicketMessage):
20 | payloads = [notion_data]
21 | else:
22 | payloads = list(notion_data)
23 |
24 | for payload in payloads:
25 | await infra.broker.publish(payload, queue=NOTION_QUEUE_NAME)
26 |
27 | return payloads
28 |
29 |
30 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/buttons/submit-button.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/atoms/button";
2 | import { RefreshCw } from "lucide-react";
3 | interface SubmutButtonProps {
4 | isUploading: boolean;
5 | isTelegramLinked: boolean;
6 | uploadProgress: number;
7 | }
8 | export function SubmitButton({
9 | isUploading,
10 | isTelegramLinked,
11 | uploadProgress,
12 | }: SubmutButtonProps) {
13 | return (
14 |
19 | {isUploading ? (
20 |
21 |
22 | Uploading... {uploadProgress}%
23 |
24 | ) : (
25 | "Create Listing"
26 | )}
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/data/kp/product.tsx:
--------------------------------------------------------------------------------
1 | import { Blocks, Book, Cable, Shirt, Armchair, WashingMachine, Volleyball, Apple, Bike, Archive} from "lucide-react";
2 | export const productCategories: Types.DisplayCategory[] = [
3 | {
4 | title: "All",
5 | icon: Blocks,
6 | },
7 | {
8 | title: "Books",
9 | icon: Book,
10 | },
11 | {
12 | title: "Electronics",
13 | icon: Cable,
14 | },
15 | {
16 | title: "Clothing",
17 | icon: Shirt,
18 | },
19 | {
20 | title: "Furniture",
21 | icon: Armchair
22 | },
23 | {
24 | title: "Appliances",
25 | icon: WashingMachine
26 | },
27 | {
28 | title: "Sports",
29 | icon: Volleyball,
30 | },
31 | {
32 | title: "Food",
33 | icon: Apple,
34 | },
35 | {
36 | title: "Transport",
37 | icon: Bike,
38 | },
39 | {
40 | title: "Others",
41 | icon: Archive,
42 | },
43 | ];
44 |
--------------------------------------------------------------------------------
/frontend/src/features/communities/hooks/use-edit-community.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "../api/communitiesApi";
3 | import { useParams } from "react-router-dom";
4 | import { Community } from "@/features/shared/campus/types";
5 |
6 | export const useEditCommunity = () => {
7 | const { id } = useParams<{ id: string }>();
8 | const queryClient = useQueryClient();
9 |
10 | return useMutation({
11 | mutationFn: (data: Community) =>
12 | campuscurrentAPI.editCommunity(data.id.toString(), data),
13 | onSuccess: (data, variables) => {
14 | queryClient.invalidateQueries({
15 | queryKey: campuscurrentAPI.getCommunityQueryOptions(variables.id.toString()).queryKey,
16 | });
17 | queryClient.invalidateQueries({ queryKey: ['campusCurrent', 'community', id] });
18 | },
19 | });
20 | };
--------------------------------------------------------------------------------
/frontend/src/components/molecules/BackButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ArrowLeft } from "lucide-react";
4 | import { cn } from "@/utils/utils";
5 | import { useBackNavigation } from "@/context/BackNavigationContext";
6 |
7 | interface BackButtonProps {
8 | className?: string;
9 | label?: string;
10 | }
11 |
12 | export function BackButton({ className, label = "Back" }: BackButtonProps) {
13 | const { triggerBack } = useBackNavigation();
14 |
15 | return (
16 |
25 |
26 | {label}
27 |
28 | );
29 | }
30 |
31 |
32 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/general-section.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from "react-router-dom";
2 | import { Button } from "../atoms/button";
3 |
4 | export function GeneralSection({
5 | title,
6 | link,
7 | children,
8 | }: {
9 | title: string;
10 | link: string;
11 | children: React.ReactNode;
12 | }) {
13 | const navigate = useNavigate();
14 | return (
15 |
16 |
17 |
{title}
18 | navigate(link)}
22 | >
23 | See All
24 |
25 |
26 |
27 |
28 | {children}
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/src/components/atoms/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/utils/utils";
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | );
20 | },
21 | );
22 | Textarea.displayName = "Textarea";
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/backend/modules/announcements/service.py:
--------------------------------------------------------------------------------
1 | import httpx
2 | import re
3 | from typing import Optional
4 | import logging
5 |
6 | logger = logging.getLogger(__name__)
7 |
8 | CHANNEL_URL = "https://t.me/s/nuspacechannel"
9 |
10 | POST_ID_PATTERN = re.compile(r'data-post="[^"]+/(\d+)"')
11 |
12 | async def get_latest_telegram_post_id() -> Optional[int]:
13 | try:
14 | async with httpx.AsyncClient() as client:
15 | response = await client.get(CHANNEL_URL, follow_redirects=True)
16 | response.raise_for_status()
17 |
18 | matches = POST_ID_PATTERN.findall(response.text)
19 |
20 | if not matches:
21 | return None
22 |
23 | latest_id = int(matches[-1])
24 | return latest_id
25 |
26 | except Exception as e:
27 | logger.error(f"Failed to fetch telegram posts: {e}")
28 | return None
--------------------------------------------------------------------------------
/frontend/src/features/courses/components/TrendIndicator.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowDown, ArrowUp } from 'lucide-react';
2 |
3 | interface TrendIndicatorProps {
4 | userScore: number;
5 | classAverage: number | null | undefined;
6 | }
7 |
8 | export function TrendIndicator({ userScore, classAverage }: TrendIndicatorProps) {
9 | if (classAverage == null) {
10 | return null;
11 | }
12 |
13 | const difference = userScore - classAverage;
14 | const isUp = difference >= 0;
15 | const colorClass = isUp ? 'text-green-500' : 'text-red-500';
16 | const Icon = isUp ? ArrowUp : ArrowDown;
17 |
18 | return (
19 |
20 |
21 |
22 | {Math.abs(difference).toFixed(1)}% {isUp ? 'above' : 'below'} avg
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/components/atoms/progress.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as ProgressPrimitive from "@radix-ui/react-progress";
3 |
4 | import { cn } from "../../utils/utils";
5 |
6 | const Progress = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, value, ...props }, ref) => (
10 |
18 |
22 |
23 | ));
24 | Progress.displayName = ProgressPrimitive.Root.displayName;
25 |
26 | export { Progress };
27 |
--------------------------------------------------------------------------------
/frontend/src/components/atoms/slider-button.tsx:
--------------------------------------------------------------------------------
1 | import { ChevronLeft, ChevronRight } from "lucide-react";
2 | import { useTheme } from "../../context/ThemeProviderContext";
3 |
4 | interface SliderButtonProps {
5 | direction: "left" | "right";
6 | onClick: () => void;
7 | }
8 |
9 | export const SliderButton = ({ direction, onClick }: SliderButtonProps) => {
10 | const { theme } = useTheme();
11 | const isDarkTheme = theme === "dark";
12 |
13 | return (
14 |
24 | {direction === "left" ? : }
25 |
26 | );
27 | };
28 |
--------------------------------------------------------------------------------
/frontend/src/components/layout/ProtectedRoute.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Outlet } from "react-router-dom";
4 | import { useUser } from "@/hooks/use-user";
5 |
6 | export function ProtectedRoute() {
7 | const { isLoading } = useUser();
8 |
9 | // Show loading state while checking authentication
10 | if (isLoading) {
11 | return (
12 |
13 |
14 |
15 |
Loading...
16 |
17 |
18 | );
19 | }
20 |
21 | // User is authenticated (or guest), render the protected content
22 | return ;
23 | }
24 |
25 | export default ProtectedRoute;
26 |
--------------------------------------------------------------------------------
/frontend/src/components/atoms/toggle.tsx:
--------------------------------------------------------------------------------
1 | import { cva, type VariantProps } from "class-variance-authority"
2 |
3 | const toggleVariants = cva(
4 | "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
5 | {
6 | variants: {
7 | variant: {
8 | default: "bg-transparent",
9 | outline:
10 | "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
11 | },
12 | size: {
13 | default: "h-10 px-3",
14 | sm: "h-9 px-2.5",
15 | lg: "h-11 px-5",
16 | },
17 | },
18 | defaultVariants: {
19 | variant: "default",
20 | size: "default",
21 | },
22 | }
23 | )
24 |
25 | export { toggleVariants }
26 |
--------------------------------------------------------------------------------
/frontend/src/components/atoms/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { cn } from "../../utils/utils";
3 |
4 | export interface InputProps
5 | extends React.InputHTMLAttributes {}
6 |
7 | const Input = React.forwardRef(
8 | ({ className, type, ...props }, ref) => {
9 | return (
10 |
19 | );
20 | },
21 | );
22 | Input.displayName = "Input";
23 |
24 | export { Input };
25 |
--------------------------------------------------------------------------------
/backend/modules/courses/degree_audit/requirements/additional_tables/Degree audit requirments for all majors - Soc_hum electives.csv:
--------------------------------------------------------------------------------
1 | HUMELECTIVE,SOCELECTIVE,HUMSOCELECTIVE
2 | HST XXX,ANT XXX,ANT 385/WLL 385
3 | PHIL XXX,ECON XXX,ANT 275
4 | REL XXX,PLS XXX,ANT 306
5 | WLL XXX,SOC XXX,PLS 102
6 | WCS 150,,PLS 325
7 | WCS 160,,PLS 326
8 | WCS 200,,PLS 329
9 | WCS 230,,PLS 421
10 | WCS 240,,PLS 422
11 | WCS 260/WLL 235,,PLS 426
12 | WCS 300,,SOC 325
13 | WCS 301,,WCS 101
14 | WCS 302,,WCS 135
15 | WCS 360/WLL 360,,WCS 201
16 | WCS 361,,WCS 203
17 | WCS 362,,WCS 204
18 | WCS 363,,WCS 205
19 | WCS 393,,WCS 206
20 | WCS 394,,WCS 210
21 | WCS 462,,WCS 220
22 | WCS 465,,WCS 250
23 | TUR 100,,WCS 270
24 | TUR 230,,WCS 304
25 | TUR 231,,WCS 305
26 | TUR 235,,WCS 390
27 | TUR 271/HST 271,,WCS 391
28 | TUR 272/HST 272,,WCS 392
29 | TUR 280/LING 280,,TUR 455
30 | TUR 375,,LING XXX
31 | TUR 411,,
32 | TUR 451,,
33 | TUR 454,,
34 | TUR 480/LING 480,,
35 | WLL 235,,
36 | WLL 360,,
--------------------------------------------------------------------------------
/frontend/src/features/communities/api/hooks/usePreSearchCommunities.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { apiCall } from "@/utils/api";
3 | import { PreSearchedItem } from "@/types/search";
4 |
5 | export const usePreSearchCommunities = (inputValue: string) => {
6 | const keyword = String(inputValue || "").trim();
7 | const { data } = useQuery({
8 | queryKey: ["pre-search-communities", keyword],
9 | enabled: !!keyword,
10 | queryFn: async ({ signal }) => {
11 | const res = await apiCall(
12 | `/search/?keyword=${encodeURIComponent(keyword)}&storage_name=communities&page=1&size=10`,
13 | { signal },
14 | );
15 | return res as Array<{ id: number | string; name: string }>;
16 | },
17 | });
18 |
19 | const preSearchedItems: PreSearchedItem[] | null = Array.isArray(data)
20 | ? data.map((c) => ({ id: c.id, name: c.name }))
21 | : null;
22 |
23 | return { preSearchedItems };
24 | };
25 |
26 |
27 |
--------------------------------------------------------------------------------
/frontend/src/assets/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nuspace",
3 | "shortname": "nuspace",
4 | "start_url": "/",
5 | "icons": [
6 | {
7 | "src": "/src/assets/icons/manifest-icon-192.maskable.png",
8 | "sizes": "192x192",
9 | "type": "image/png",
10 | "purpose": "any"
11 | },
12 | {
13 | "src": "/src/assets/icons/manifest-icon-192.maskable.png",
14 | "sizes": "192x192",
15 | "type": "image/png",
16 | "purpose": "maskable"
17 | },
18 | {
19 | "src": "/src/assets/icons/manifest-icon-512.maskable.png",
20 | "sizes": "512x512",
21 | "type": "image/png",
22 | "purpose": "any"
23 | },
24 | {
25 | "src": "/src/assets/icons/manifest-icon-512.maskable.png",
26 | "sizes": "512x512",
27 | "type": "image/png",
28 | "purpose": "maskable"
29 | }
30 | ],
31 | "theme_color": "#000000",
32 | "background_color": "#FFFFFF",
33 | "display": "fullscreen",
34 | "orientation": "portrait"
35 | }
36 |
--------------------------------------------------------------------------------
/backend/modules/bot/routes/user/private/callback/confirmation.py:
--------------------------------------------------------------------------------
1 | from typing import Callable
2 |
3 | from aiogram import Router
4 | from aiogram.types import CallbackQuery
5 | from sqlalchemy.ext.asyncio import AsyncSession
6 | from backend.modules.bot.cruds import set_telegram_id
7 | from backend.modules.bot.keyboards.callback_factory import ConfirmTelegramUser
8 |
9 | router = Router()
10 |
11 |
12 | @router.callback_query(ConfirmTelegramUser.filter())
13 | async def confirmation_buttons(
14 | c: CallbackQuery,
15 | callback_data: ConfirmTelegramUser,
16 | db_session: AsyncSession,
17 | _: Callable[[str], str],
18 | ) -> None:
19 |
20 | if callback_data.number == callback_data.confirmation_number:
21 | await set_telegram_id(session=db_session, sub=callback_data.sub, user_id=c.from_user.id)
22 | await c.message.answer(_("Телеграм аккаунт успешно привязан!"))
23 | else:
24 | await c.message.answer(_("Введенный вами символ неверный!"))
25 | await c.message.delete()
26 |
--------------------------------------------------------------------------------
/backend/modules/courses/registrar/tests/test_priority_sync_worker.py:
--------------------------------------------------------------------------------
1 | import json
2 | from pathlib import Path
3 |
4 | import pytest
5 |
6 | from backend.modules.courses.registrar import priority_sync_worker as worker
7 |
8 |
9 | @pytest.mark.asyncio
10 | async def test_worker_writes_output(tmp_path, monkeypatch):
11 | monkeypatch.setenv("PRIORITY_SYNC__PDF_URL", "https://example.com/pdf")
12 | output_path = tmp_path / "priorities.json"
13 | monkeypatch.setenv("PRIORITY_SYNC__OUTPUT_PATH", str(output_path))
14 |
15 | async def fake_download(url: str) -> bytes:
16 | assert url == "https://example.com/pdf"
17 | return b"%PDF-FAKE%"
18 |
19 | monkeypatch.setattr(worker, "_download", fake_download)
20 | monkeypatch.setattr(worker, "parse_pdf", lambda _: [{"id": 1, "abbr": "MATH 162"}])
21 |
22 | await worker.main()
23 |
24 | assert output_path.exists()
25 | data = json.loads(output_path.read_text())
26 | assert data == [{"id": 1, "abbr": "MATH 162"}]
27 |
28 |
29 |
--------------------------------------------------------------------------------
/frontend/src/app/main.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClientProvider } from "@tanstack/react-query";
2 |
3 | import React from "react";
4 | import ReactDOM from "react-dom/client";
5 | import { BrowserRouter } from "react-router-dom";
6 | import App from "./App";
7 | import { BackNavigationProvider } from "@/context/BackNavigationContext";
8 | import { ThemeProvider } from "@/context/ThemeProviderContext";
9 | import "./index.css";
10 | import { queryClient } from "../utils/query-client";
11 |
12 | ReactDOM.createRoot(document.getElementById("root")!).render(
13 |
14 |
15 |
16 |
17 | {/* Provider is already used inside AppsLayout where needed; keep here for any global consumers */}
18 |
19 |
20 |
21 |
22 |
23 |
24 | ,
25 | );
26 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/buttons/donate-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { Button } from "../../atoms/button";
5 | import { Heart } from "lucide-react";
6 | import { DonateModal } from "./donate-modal";
7 |
8 | export function DonateButton() {
9 | const [isModalOpen, setIsModalOpen] = useState(false);
10 |
11 | return (
12 | <>
13 | setIsModalOpen(true)}
17 | className="flex items-center gap-2 text-pink-600 border-pink-200 hover:bg-pink-50 hover:border-pink-300 dark:text-pink-400 dark:border-pink-800 dark:hover:bg-pink-900/20 dark:hover:border-pink-700 min-w-0"
18 | >
19 |
20 | Support
21 |
22 |
23 | setIsModalOpen(false)}
26 | />
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/buttons/login-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "../../atoms/button";
4 | import { User, LogOut } from "lucide-react";
5 | import { useUser } from "@/hooks/use-user";
6 |
7 | export function LoginButton() {
8 | const { user, login, logout } = useUser();
9 |
10 | return (
11 |
12 | {user ? (
13 |
19 |
20 | Logout
21 |
22 | ) : (
23 |
29 |
30 | Login
31 |
32 | )}
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Python cache files (ignore __pycache__ in any directory)
2 | **/__pycache__/
3 | **/*.pyc
4 | **/*.pyo
5 | **/*.pyd
6 |
7 | # Virtual environment
8 | venv/
9 |
10 | # Terraform credentials and state files
11 | **/terraform.json
12 | terraform/creds/
13 | *.tfstate
14 | *.tfstate.*
15 | .terraform/
16 |
17 | # PyCharm IDE files
18 | .idea/
19 |
20 | # Node.js dependencies
21 | node_modules/
22 |
23 | # Build output (Vite/React)
24 | dist/
25 | ssl/
26 | # Static folder pycache
27 | static/**/__pycache__/
28 |
29 | # Configs folder pycache
30 | configs/**/__pycache__/
31 |
32 | # Environmental files
33 | .env
34 |
35 | # Cloudflared local dev credentials
36 | infra/cloudflared/
37 |
38 | .ansible/
39 |
40 | # Log files
41 | *.log
42 |
43 | # OS-specific files
44 | .DS_Store
45 | **/.DS_Store
46 | Thumbs.db
47 |
48 |
49 | **/.vscode/
50 |
51 | # Loki bucket service account
52 | **/nuspace_bucket.json
53 |
54 | **/.ruff_cache/
55 | **/.pytest_cache/
56 |
57 | # CapacitorJS build files
58 | frontend/ios
59 | frontend/android
60 |
--------------------------------------------------------------------------------
/backend/modules/campuscurrent/base.py:
--------------------------------------------------------------------------------
1 | from fastapi import HTTPException
2 | from fastapi import status as http_status
3 |
4 | from backend.core.database.models.user import UserRole
5 |
6 |
7 | class BasePolicy:
8 | """Base policy with common user attributes."""
9 |
10 | def __init__(self, user: tuple[dict, dict]):
11 | if not user or not user[0] or not user[1]:
12 | raise HTTPException(
13 | status_code=http_status.HTTP_401_UNAUTHORIZED,
14 | detail="Authentication credentials were not provided",
15 | )
16 | self.user_creds = user
17 | self.user_role = user[1]["role"]
18 | self.user_sub = user[0]["sub"]
19 | self.communities = user[1]["communities"]
20 | self.is_admin = self.user_role == UserRole.admin.value
21 |
22 | def _is_owner(self, author_sub: str) -> bool:
23 | return self.user_sub == author_sub
24 |
25 | def _is_community_head(self, community_id: int) -> bool:
26 | return community_id in self.communities
27 |
--------------------------------------------------------------------------------
/backend/modules/courses/statistics/schemas.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from typing import List
3 | from fastapi import Query
4 | from pydantic import BaseModel
5 |
6 |
7 |
8 |
9 | class BaseGradeReportSchema(BaseModel):
10 | id: int
11 | course_code: str
12 | course_title: str
13 | section: str
14 | term: str
15 | grades_count: int
16 | avg_gpa: float
17 | std_dev: float
18 | median_gpa: float
19 | pct_A: float
20 | pct_B: float
21 | pct_C: float
22 | pct_D: float
23 | pct_F: float
24 | pct_P: float
25 | pct_I: float
26 | pct_AU: float
27 | pct_W_AW: float
28 | letters_count: int
29 | faculty: str
30 | created_at: datetime
31 | updated_at: datetime
32 |
33 | class Config:
34 | from_attributes = True
35 |
36 |
37 | class ListGradeReportResponse(BaseModel):
38 | grades: List[BaseGradeReportSchema] = []
39 | total_pages: int = Query(1, ge=1)
40 |
41 |
42 | class ListGradeTermsResponse(BaseModel):
43 | terms: List[str] = []
44 |
45 |
--------------------------------------------------------------------------------
/frontend/src/features/communities/hooks/useInfiniteCommunities.ts:
--------------------------------------------------------------------------------
1 | import { useInfiniteScroll, useInfiniteScrollWithWindow } from '@/hooks/useInfiniteScroll';
2 | import { Community } from '@/features/shared/campus/types';
3 | import * as Routes from '@/data/routes';
4 |
5 | export type UseInfiniteCommunitiesParams = {
6 | keyword?: string;
7 | category?: string | null;
8 | recruitment_status?: 'open' | 'closed' | null;
9 | size?: number;
10 | };
11 |
12 | export function useInfiniteCommunities(params: UseInfiniteCommunitiesParams = {}) {
13 | const {
14 | keyword = "",
15 | category,
16 | recruitment_status,
17 | size = 12,
18 | } = params;
19 |
20 | const infiniteScrollReturn = useInfiniteScroll({
21 | queryKey: ["campusCurrent", "communities"],
22 | apiEndpoint: `/${Routes.COMMUNITIES}`,
23 | size,
24 | keyword,
25 | additionalParams: {
26 | category,
27 | recruitment_status,
28 | },
29 | });
30 |
31 | return useInfiniteScrollWithWindow(infiniteScrollReturn);
32 | }
33 |
--------------------------------------------------------------------------------
/frontend/src/features/media/utils/upload-media.ts:
--------------------------------------------------------------------------------
1 | export const uploadMedia = async (files: File[], signedUrls: any[]) => {
2 | return Promise.all(
3 | files.map((file: File, i: number) => {
4 | const {
5 | upload_url,
6 | filename,
7 | entity_type,
8 | entity_id,
9 | media_format,
10 | media_order,
11 | mime_type,
12 | } = signedUrls[i];
13 |
14 | const headers: Record = {
15 | "x-goog-meta-filename": filename,
16 | "x-goog-meta-media-table": entity_type,
17 | "x-goog-meta-entity-id": entity_id.toString(),
18 | "x-goog-meta-media-format": media_format,
19 | "x-goog-meta-media-order": media_order.toString(),
20 | "x-goog-meta-mime-type": mime_type,
21 | "Content-Type": mime_type,
22 | };
23 |
24 | return fetch(upload_url, {
25 | method: "PUT",
26 | headers,
27 | body: file,
28 | });
29 | }),
30 | );
31 | };
--------------------------------------------------------------------------------
/backend/modules/bot/utils/google_bucket.py:
--------------------------------------------------------------------------------
1 | from datetime import timedelta
2 |
3 | from google.cloud import storage
4 |
5 | from backend.core.configs.config import config
6 | from backend.modules.google_bucket.utils import load_signing_credentials_from_info
7 |
8 |
9 | async def generate_download_url(storage_client: storage.Client, filename: str):
10 | """
11 | Generates a signed download URL with:
12 | - 15 minute expiration
13 | - GET access only
14 | - Requires valid JWT
15 | """
16 | # Generate signed URL using impersonated credentials to avoid private key requirement
17 | signing_credentials = load_signing_credentials_from_info(config.SIGNING_SERVICE_ACCOUNT_INFO)
18 |
19 | blob = storage_client.bucket(config.BUCKET_NAME).blob(filename)
20 |
21 | signed_url = blob.generate_signed_url(
22 | version="v4",
23 | expiration=timedelta(minutes=15),
24 | method="GET",
25 | response_type="image/jpeg",
26 | credentials=signing_credentials,
27 | )
28 | return signed_url
29 |
--------------------------------------------------------------------------------
/frontend/src/components/atoms/sonner.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "next-themes";
2 | import { Toaster as Sonner, toast } from "sonner";
3 |
4 | type ToasterProps = React.ComponentProps;
5 |
6 | const Toaster = ({ ...props }: ToasterProps) => {
7 | const { theme = "system" } = useTheme();
8 |
9 | return (
10 |
26 | );
27 | };
28 |
29 | export { Toaster, toast };
30 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/buttons/channel-button.tsx:
--------------------------------------------------------------------------------
1 | import { Radio } from "lucide-react";
2 | import { Button } from "@/components/atoms/button";
3 |
4 | export function ChannelButton({
5 | className,
6 | text = "Channel",
7 | }: {
8 | className?: string;
9 | text?: string;
10 | }) {
11 | return (
12 |
28 |
34 |
35 | {text}
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/frontend/src/features/courses/api/hooks/usePreSearchGrades.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { apiCall } from "@/utils/api";
3 | import { PreSearchedItem } from "@/types/search";
4 |
5 | export const usePreSearchGrades = (inputValue: string) => {
6 | const keyword = String(inputValue || "").trim();
7 | const { data } = useQuery({
8 | queryKey: ["pre-search-grades", keyword],
9 | enabled: !!keyword,
10 | queryFn: async ({ signal }) => {
11 | const res = await apiCall(
12 | `/search/?keyword=${encodeURIComponent(keyword)}&storage_name=grade_reports&page=1&size=10`,
13 | { signal },
14 | );
15 | return res as Array<{ id: number | string; course_code: string; course_title: string }>;
16 | },
17 | });
18 |
19 | const preSearchedItems: PreSearchedItem[] | null = Array.isArray(data)
20 | ? data.map((grade) => ({
21 | id: grade.id,
22 | name: `${grade.course_code} - ${grade.course_title}`
23 | }))
24 | : null;
25 |
26 | return { preSearchedItems };
27 | };
28 |
--------------------------------------------------------------------------------
/backend/modules/notion/consts.py:
--------------------------------------------------------------------------------
1 | # broker queue name
2 | NOTION_QUEUE_NAME = "notion.tickets"
3 |
4 | # API configuration
5 | NOTION_API_BASE_URL = "https://api.notion.com/v1"
6 | NOTION_API_VERSION = "2025-09-03" # Modern API version - uses data_sources for schema
7 | DEFAULT_TIMEOUT = 15.0
8 |
9 | # temporary lock (deleted after consumption by faststream broker).
10 | # Used by NotionService to decide whether to send message to broker or not.
11 | NOTION_SYNC_REDIS_PREFIX = "notion:ticket-sync"
12 | NOTION_SYNC_TTL_SECONDS = 60 * 60 * 24 * 15 # 15 days
13 |
14 | # persistent data (kept for future operations)
15 | NOTION_PAGE_ID_REDIS_PREFIX = "notion:page-id"
16 | NOTION_PAGE_ID_TTL_SECONDS = 60 * 60 * 24 * 365 # 1 year
17 | NOTION_BLOCK_ID_REDIS_PREFIX = "notion:block-id"
18 | NOTION_BLOCK_ID_TTL_SECONDS = 60 * 60 * 24 * 365 # 1 year
19 |
20 |
21 | # later we will add database_id of each SG department and map them to the department id
22 | NOTION_TICKET_DATABASE_ID = "2a580237cb508105982ef744d8f35cb1"
23 | NOTION_TICKET_URL_TEMPLATE = "https://nuspace.kz/apps/sgotinish/sg/ticket/"
--------------------------------------------------------------------------------
/frontend/src/components/molecules/telegram-status.tsx:
--------------------------------------------------------------------------------
1 | import { FaTelegram } from "react-icons/fa";
2 | import { Badge } from "@/components/atoms/badge";
3 | import { useTheme } from "@/context/ThemeProviderContext";
4 |
5 | interface TelegramStatusProps {
6 | isConnected: boolean;
7 | className?: string;
8 | }
9 |
10 | export function TelegramStatus({
11 | isConnected,
12 | className = "",
13 | }: TelegramStatusProps) {
14 | const { theme } = useTheme();
15 | const isDark = theme === "dark";
16 |
17 | if (!isConnected) return null;
18 |
19 | return (
20 |
36 |
37 | Connected
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/backend/modules/bot/middlewares/__init__.py:
--------------------------------------------------------------------------------
1 | from aiogram import Dispatcher
2 | from google.cloud import storage
3 | from redis.asyncio import Redis
4 |
5 | from backend.core.database.manager import AsyncDatabaseManager
6 |
7 | from .bucket_client import BucketClientMiddleware
8 | from .db_session import DatabaseMiddleware
9 | from .i18n import I18N
10 | from .public_url import UrlMiddleware
11 | from .redis import RedisMiddleware
12 |
13 |
14 | def setup_middlewares(
15 | dp: Dispatcher,
16 | url: str,
17 | redis: Redis,
18 | db_manager: AsyncDatabaseManager,
19 | storage_client: storage.Client,
20 | ):
21 | middlewares = [
22 | DatabaseMiddleware(db_manager),
23 | RedisMiddleware(redis),
24 | UrlMiddleware("https://t.me/NUspaceBot/app"),
25 | I18N(),
26 | BucketClientMiddleware(storage_client),
27 | ]
28 | for middleware in middlewares:
29 | dp.update.middleware(middleware)
30 | dp.message.middleware(middleware)
31 | dp.callback_query.middleware(middleware)
32 | dp.chat_member.middleware(middleware)
33 |
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | # Use official Python 3.12 slim image
2 | FROM python:3.12-alpine3.22
3 |
4 | # Set build arguments
5 | ARG IS_DEBUG=true
6 |
7 | # Set timezone and Python settings
8 | ENV TZ=UTC \
9 | PYTHONUNBUFFERED=1 \
10 | PYTHONDONTWRITEBYTECODE=1 \
11 | PYTHONPATH=/nuros \
12 | POETRY_NO_INTERACTION=1 \
13 | POETRY_VIRTUALENVS_IN_PROJECT=1 \
14 | POETRY_VIRTUALENVS_CREATE=0 \
15 | POETRY_CACHE_DIR=/tmp/poetry_cache
16 |
17 | WORKDIR /nuros
18 | ENV PATH="/root/.local/bin:${PATH}"
19 |
20 | RUN apk -u add \
21 | build-base \
22 | python3-dev \
23 | libpq-dev \
24 | gcc \
25 | g++ \
26 | musl-dev \
27 | linux-headers \
28 | && pip install --no-cache-dir poetry
29 |
30 |
31 | COPY backend/pyproject.toml backend/poetry.lock ./
32 | COPY backend/start.sh /
33 | RUN poetry install --no-root --no-cache
34 |
35 | COPY .. .
36 |
37 | COPY backend/start.sh /
38 | RUN sed -i 's/\r$//' /start.sh
39 | RUN chmod +x /start.sh
40 | RUN apk add --no-cache bash
41 | # Run the script to decide the server behavior based on IS_DEBUG
42 | CMD ["/start.sh"]
43 |
--------------------------------------------------------------------------------
/backend/core/database/models/notification.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from backend.core.database.models.base import Base
4 | from backend.core.database.models.common_enums import EntityType, NotificationType
5 | from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, String
6 | from sqlalchemy.orm import Mapped, mapped_column
7 |
8 |
9 | class Notification(Base):
10 | __tablename__ = "notifications"
11 |
12 | id: Mapped[int] = mapped_column(Integer, primary_key=True)
13 | title: Mapped[str] = mapped_column(String, nullable=False)
14 | message: Mapped[str] = mapped_column(String, nullable=False)
15 | notification_source: Mapped[EntityType] = mapped_column(String, nullable=False)
16 | receiver_sub: Mapped[str] = mapped_column(ForeignKey("users.sub"), nullable=False)
17 | type: Mapped[NotificationType] = mapped_column(String, nullable=False)
18 | tg_id: Mapped[int] = mapped_column(BigInteger, nullable=False)
19 | url: Mapped[str] = mapped_column(String, nullable=True)
20 | created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
21 |
--------------------------------------------------------------------------------
/frontend/src/utils/search-params.ts:
--------------------------------------------------------------------------------
1 | const DEFAULT_PAGE = 1;
2 |
3 | export const getSearchTextFromURL = (query: string) => {
4 | const params = new URLSearchParams(query);
5 | return params.get("text") || "";
6 | };
7 |
8 | export const getSearchParamFromURL = (
9 | query: string,
10 | key: string = "text",
11 | ): string => {
12 | const params = new URLSearchParams(query);
13 | return params.get(key) || "";
14 | };
15 |
16 | export const getSeachPageFromURL = (query: string) => {
17 | const params = new URLSearchParams(query);
18 | return Number(params.get("page")) || DEFAULT_PAGE;
19 | };
20 |
21 | export const getSearchCategoryFromURL = (query: string) => {
22 | const params = new URLSearchParams(query);
23 | return params.get("category") || "All";
24 | };
25 | export const getSearchConditionFromURL = (query: string) => {
26 | const params = new URLSearchParams(query);
27 | return params.get("condition") || "All Conditions";
28 | };
29 |
30 | export const getProductIdFromURL = (query: string) => {
31 | const params = new URLSearchParams(query);
32 | console.log(params.get("id"));
33 | };
34 |
--------------------------------------------------------------------------------
/frontend/src/components/animations/AnimatedCard.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from "framer-motion";
2 | import { Card } from "../atoms/card";
3 | import { ReactNode } from "react";
4 |
5 | interface AnimatedCardProps {
6 | children: ReactNode;
7 | hasFloatingBackground?: boolean;
8 | backgroundEffects?: ReactNode;
9 | variants?: any;
10 | className?: string;
11 | [key: string]: any;
12 | }
13 |
14 | export function AnimatedCard({
15 | children,
16 | hasFloatingBackground = false,
17 | backgroundEffects,
18 | variants,
19 | className = "",
20 | ...props
21 | }: AnimatedCardProps) {
22 | return (
23 |
24 |
28 | {hasFloatingBackground && (
29 |
30 | {backgroundEffects}
31 |
32 | )}
33 |
34 | {children}
35 |
36 |
37 |
38 | );
39 | }
--------------------------------------------------------------------------------
/backend/modules/notification/schemas.py:
--------------------------------------------------------------------------------
1 | import datetime
2 |
3 | from pydantic import BaseModel, field_validator
4 |
5 | from backend.core.database.models.common_enums import EntityType, NotificationType
6 |
7 |
8 | class RequestNotiification(BaseModel):
9 | title: str
10 | message: str
11 | notification_source: EntityType
12 | receiver_sub: str
13 | telegram_id: int | None = None
14 | type: NotificationType
15 | url: str | None = None
16 |
17 | @field_validator("url")
18 | def validate_url(cls, v):
19 | if isinstance(v, str) and (not v.startswith("https://")):
20 | raise ValueError("URL must start with https://")
21 | return v
22 |
23 |
24 | class BaseNotification(BaseModel):
25 | id: int
26 | title: str
27 | message: str
28 | notification_source: EntityType
29 | receiver_sub: str
30 | tg_id: int
31 | type: NotificationType
32 | url: str | None = None
33 | created_at: datetime.datetime
34 |
35 | class Config:
36 | from_attributes = True
37 |
38 |
39 | class _RequestNotification(BaseNotification):
40 | switch: bool
41 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/animations/AnimatedCard.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from "framer-motion";
2 | import { Card } from "../../atoms/card";
3 | import { ReactNode } from "react";
4 |
5 | interface AnimatedCardProps {
6 | children: ReactNode;
7 | hasFloatingBackground?: boolean;
8 | backgroundEffects?: ReactNode;
9 | variants?: any;
10 | className?: string;
11 | [key: string]: any;
12 | }
13 |
14 | export function AnimatedCard({
15 | children,
16 | hasFloatingBackground = false,
17 | backgroundEffects,
18 | variants,
19 | className = "",
20 | ...props
21 | }: AnimatedCardProps) {
22 | return (
23 |
24 |
28 | {hasFloatingBackground && (
29 |
30 | {backgroundEffects}
31 |
32 | )}
33 |
34 | {children}
35 |
36 |
37 |
38 | );
39 | }
--------------------------------------------------------------------------------
/frontend/src/components/organisms/about/mission-section.tsx:
--------------------------------------------------------------------------------
1 | import { useTheme } from "@/context/ThemeProviderContext";
2 |
3 | export const MessionSection = () => {
4 | const { theme } = useTheme();
5 | const isDark = theme === "dark";
6 | return (
7 |
12 |
Mission
13 |
14 | Nuspace is a single platform for Nazarbayev University students. Our
15 | goal is to simplify the daily life of students and make campus life more
16 | comfortable.
17 |
18 |
19 | We strive to create a reliable platform that will allow every student of
20 | Nazarbayev University to make the most of their time, easily find the
21 | necessary things and keep abreast of interesting events and
22 | opportunities within the university.
23 |
24 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/infra/scripts/start-alertmanager.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Exit on any error
4 | # tells to exit the script if any command fails e.g false
5 | set -e
6 |
7 | # Check if required environment variables are set
8 | if [ -z "$TELEGRAM_BOT_TOKEN" ]; then
9 | echo "ERROR: TELEGRAM_BOT_TOKEN environment variable is not set"
10 | exit 1
11 | fi
12 |
13 | if [ -z "$TELEGRAM_CHAT_ID" ]; then
14 | echo "ERROR: TELEGRAM_CHAT_ID environment variable is not set"
15 | exit 1
16 | fi
17 |
18 | # Process template with envsubst if available, otherwise use sed
19 | echo "Processing Alertmanager template..."
20 | if command -v envsubst >/dev/null 2>&1; then
21 | envsubst < /etc/alertmanager/alertmanager.yml.tpl > /etc/alertmanager/alertmanager.yml
22 | else
23 | # Fallback using sed for environment variable substitution
24 | sed "s/\${TELEGRAM_BOT_TOKEN}/$TELEGRAM_BOT_TOKEN/g; s/\${TELEGRAM_CHAT_ID}/$TELEGRAM_CHAT_ID/g" \
25 | /etc/alertmanager/alertmanager.yml.tpl > /etc/alertmanager/alertmanager.yml
26 | fi
27 |
28 | # Start Alertmanager
29 | echo "Starting Alertmanager..."
30 | exec /bin/alertmanager --config.file=/etc/alertmanager/alertmanager.yml
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Ulan Sharipov
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/backend/modules/sgotinish/base.py:
--------------------------------------------------------------------------------
1 | from backend.core.database.models.user import UserRole
2 |
3 |
4 | from fastapi import HTTPException
5 | from fastapi import status as http_status
6 |
7 |
8 | class BasePolicy:
9 | """Base policy with common user attributes."""
10 |
11 | def __init__(self, user_creds: tuple[dict, dict]):
12 | if not user_creds or not user_creds[0] or not user_creds[1]:
13 | raise HTTPException(
14 | status_code=http_status.HTTP_401_UNAUTHORIZED,
15 | detail="Authentication credentials were not provided",
16 | )
17 | self.user_creds = user_creds
18 | self.user_role = user_creds[1]["role"]
19 | self.user_sub = user_creds[0]["sub"]
20 | self.department_id = user_creds[1].get("department_id")
21 | self.is_admin = self.user_role == UserRole.admin.value
22 | self.is_sg_member = self.user_role in [
23 | UserRole.boss.value,
24 | UserRole.capo.value,
25 | UserRole.soldier.value,
26 | ]
27 |
28 | def _is_owner(self, author_sub: str) -> bool:
29 | return self.user_sub == author_sub
30 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/animations/AnimatedFormField.tsx:
--------------------------------------------------------------------------------
1 | import { motion } from "framer-motion";
2 | import { Label } from "../../atoms/label";
3 | import { ReactNode } from "react";
4 |
5 | interface AnimatedFormFieldProps {
6 | label: string;
7 | icon: ReactNode;
8 | fieldName: string;
9 | isFocused: boolean;
10 | children: ReactNode;
11 | showFocusIndicator?: boolean;
12 | focusColor?: string;
13 | className?: string;
14 | }
15 |
16 | export function AnimatedFormField({
17 | label,
18 | icon,
19 | fieldName,
20 | isFocused,
21 | children,
22 | showFocusIndicator = true,
23 | focusColor = "primary",
24 | className = ""
25 | }: AnimatedFormFieldProps) {
26 | return (
27 |
28 |
29 |
30 | {icon}
31 |
32 |
33 | {label}
34 |
35 |
36 |
37 |
38 | {children}
39 |
40 |
41 | );
42 | }
--------------------------------------------------------------------------------
/frontend/src/components/organisms/about/feature-card.tsx:
--------------------------------------------------------------------------------
1 | import { IconType } from "react-icons/lib";
2 | import { Card } from "@/components/atoms/card";
3 | import { useTheme } from "@/context/ThemeProviderContext";
4 | interface FeatureCardProps {
5 | title: string;
6 | description: string;
7 | icon: IconType;
8 | iconSize?: number;
9 | iconColor?: string;
10 | }
11 |
12 | export const FeatureCard = ({
13 | title,
14 | description,
15 | icon: Icon,
16 | iconSize = 36,
17 | iconColor = "text-indigo-500",
18 | }: FeatureCardProps) => {
19 | const { theme } = useTheme();
20 | const isDark = theme === "dark";
21 | return (
22 |
27 |
28 |
29 |
30 | {title}
31 |
32 | {description}
33 |
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/infra/scripts/start-grafana.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # Exit on any error
4 | set -e
5 |
6 | # Check if required environment variables are set
7 | if [ -z "$TELEGRAM_BOT_TOKEN" ]; then
8 | echo "ERROR: TELEGRAM_BOT_TOKEN environment variable is not set"
9 | exit 1
10 | fi
11 |
12 | if [ -z "$TELEGRAM_CHAT_ID" ]; then
13 | echo "ERROR: TELEGRAM_CHAT_ID environment variable is not set"
14 | exit 1
15 | fi
16 |
17 | # Process Grafana alerting templates
18 | echo "Processing Grafana alerting templates..."
19 |
20 | # Process contact-points template
21 | if command -v envsubst >/dev/null 2>&1; then
22 | envsubst < /etc/grafana/provisioning/alerting/contact-points.yaml.tpl > /etc/grafana/provisioning/alerting/contact-points.yaml
23 | else
24 | # Fallback using sed for environment variable substitution
25 | sed "s/\${TELEGRAM_BOT_TOKEN}/$TELEGRAM_BOT_TOKEN/g; s/\${TELEGRAM_CHAT_ID}/$TELEGRAM_CHAT_ID/g" \
26 | /etc/grafana/provisioning/alerting/contact-points.yaml.tpl > /etc/grafana/provisioning/alerting/contact-points.yaml
27 | fi
28 |
29 | echo "Grafana alerting templates processed successfully"
30 |
31 | # Start Grafana
32 | echo "Starting Grafana..."
33 | exec /run.sh
--------------------------------------------------------------------------------
/frontend/src/components/molecules/buttons/report-button.tsx:
--------------------------------------------------------------------------------
1 | import { Bug } from "lucide-react";
2 | import { Button } from "@/components/atoms/button";
3 | import { useTheme } from "@/context/ThemeProviderContext";
4 |
5 | export function ReportButton({
6 | className,
7 | text = "Report Bug",
8 | }: {
9 | className?: string;
10 | text?: string;
11 | }) {
12 | const { theme } = useTheme();
13 | const isDark = theme === "dark";
14 |
15 | return (
16 |
32 |
38 |
39 | {text}
40 |
41 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/backend/modules/courses/registrar/tests/test_registrar_parser.py:
--------------------------------------------------------------------------------
1 | from backend.modules.courses.registrar.parsers.registrar_parser import parse_schedule
2 |
3 |
4 | def build_entry(header: str) -> list[dict[str, str]]:
5 | return [
6 | {
7 | "MONDAY": " ".join(
8 | [
9 | header,
10 | "Lecture / 10:30 - 11:45",
11 | "6 ECTS credits",
12 | "Jonathan Dupuy",
13 | "6.410",
14 | ]
15 | )
16 | }
17 | ]
18 |
19 |
20 | def test_multi_code_header_preserved() -> None:
21 | data = build_entry("WLL 235/WCS 260 Creative Writing I")
22 |
23 | schedule = parse_schedule(data)
24 |
25 | entry = schedule.data[0][0]
26 | assert entry.course_code == "WLL 235/WCS 260"
27 | assert entry.label == "Creative Writing I"
28 |
29 |
30 | def test_fallback_header_without_code() -> None:
31 | data = build_entry("Creative Writing I")
32 |
33 | schedule = parse_schedule(data)
34 |
35 | entry = schedule.data[0][0]
36 | assert entry.course_code == "CREATIVE_WRITING_I"
37 | assert entry.label == "Creative Writing I"
38 |
39 |
--------------------------------------------------------------------------------
/frontend/src/features/courses/components/live-gpa/SummaryCards.tsx:
--------------------------------------------------------------------------------
1 | import type { LiveGpaViewModel } from "../../hooks/useLiveGpaViewModel";
2 |
3 | interface SummaryCardsProps {
4 | metrics: LiveGpaViewModel["metrics"];
5 | }
6 |
7 | export function SummaryCards({ metrics }: SummaryCardsProps) {
8 | const summaryData = [
9 | { label: "Total GPA", value: metrics.totalGPA.toFixed(2), helper: "All courses" },
10 | { label: "Max potential", value: metrics.maxPotentialGPA.toFixed(2), helper: "If perfect" },
11 | { label: "Projected", value: metrics.projectedGPA.toFixed(2), helper: "Current trend" },
12 | ];
13 |
14 | return (
15 |
16 | {summaryData.map((summary) => (
17 |
18 |
{summary.label}
19 |
{summary.value}
20 |
{summary.helper}
21 |
22 | ))}
23 |
24 | );
25 | }
26 |
27 |
--------------------------------------------------------------------------------
/terraform/.terraform.lock.hcl:
--------------------------------------------------------------------------------
1 | # This file is maintained automatically by "terraform init".
2 | # Manual edits may be lost in future updates.
3 |
4 | provider "registry.terraform.io/hashicorp/google" {
5 | version = "6.49.0"
6 | hashes = [
7 | "h1:2u4IviU5KYeY2sXwBWO9zk3tdVPttmRxZ1MZWsAxIsg=",
8 | "zh:1042513435b971ffbff5aeb9f7403374befe5431260944ecfbfbd1ff4d1993b9",
9 | "zh:135ec788db66381cd8be034c1f7bb18d801fa0537985edd0c1cae13c89c3b37e",
10 | "zh:2f80f2ad4b6daff7f019d4148a785af0636c5ccf76f6e277d136b19eb204ea00",
11 | "zh:37a3b686a751e46c61da529e9be2007434ba556b7fc8ecaf713c7b7c2a0a2b7f",
12 | "zh:39ef0060fc86c672f9aa817ecd389c11837063c00d08ca6c3e69369153e9424f",
13 | "zh:3b342bd8c9cdae59a88ca5e91283db958e89fdda8563f239c5547b3946466820",
14 | "zh:6a067dcd3e0321135f9867b35827bc5e5d11312b6e0a0b35d1411618b2752b98",
15 | "zh:7de572584ed0c5a0204f9ec925c75f467db9f460c268ce9ce24242d085b585ff",
16 | "zh:8be0c36fbdb7f0e2955a08283095069056d576d967c2fedbd64a95fc7586d3cd",
17 | "zh:8e4cb970df609e284ec5829ebe33933fb0f8181ded39c043c01e3c7e30dc8d4a",
18 | "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
19 | "zh:ff6dabaa5e005c2e7268b2bb5e9e461e5c326b698bce0ecfb1d6d078a90dd44f",
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/backend/core/database/models/degree_audit.py:
--------------------------------------------------------------------------------
1 | from __future__ import annotations
2 |
3 | from datetime import datetime
4 |
5 | from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, UniqueConstraint, text
6 | from sqlalchemy.dialects.postgresql import JSONB
7 |
8 | from .base import Base
9 |
10 |
11 | class DegreeAuditResult(Base):
12 | __tablename__ = "degree_audit_results"
13 | __table_args__ = (
14 | UniqueConstraint("student_sub", "admission_year", "major", name="uq_degree_audit_user_year_major"),
15 | )
16 |
17 | id = Column(Integer, primary_key=True, index=True)
18 | student_sub = Column(String, ForeignKey("users.sub", ondelete="CASCADE"), nullable=False, index=True)
19 | admission_year = Column(String(16), nullable=False)
20 | major = Column(String(256), nullable=False)
21 | results = Column(JSONB, nullable=False)
22 | summary = Column(JSONB, nullable=True)
23 | warnings = Column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
24 | csv_base64 = Column(String, nullable=True)
25 | created_at = Column(DateTime, nullable=False, server_default=text("NOW()"))
26 | updated_at = Column(DateTime, nullable=False, server_default=text("NOW()"), onupdate=datetime.utcnow)
27 |
28 |
29 |
--------------------------------------------------------------------------------
/frontend/src/components/atoms/tooltip.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip";
5 | import { cn } from "@/utils/utils";
6 |
7 | const TooltipProvider = TooltipPrimitive.Provider;
8 | const Tooltip = TooltipPrimitive.Root;
9 | const TooltipTrigger = TooltipPrimitive.Trigger;
10 |
11 | const TooltipContent = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, sideOffset = 4, ...props }, ref) => (
15 |
27 | ));
28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName;
29 |
30 | export { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent };
31 |
32 |
33 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/category-grid.tsx:
--------------------------------------------------------------------------------
1 | import { CategoryCard } from "@/components/atoms/category-card";
2 |
3 | interface CategoryGridProps {
4 | categories: { title: string; icon?: JSX.Element }[];
5 | selectedCategory: string | "";
6 | setPage?: (page: number) => void;
7 | setSelectedCategory: (category: string) => void;
8 | setInputValue?: (value: string) => void;
9 | setSelectedCondition?: (condition: string) => void;
10 | onCategorySelect: (title: string) => void;
11 | }
12 |
13 | export function CategoryGrid({
14 | categories,
15 | selectedCategory,
16 | onCategorySelect,
17 | }: CategoryGridProps) {
18 | return (
19 |
20 | {/* True Grid Layout */}
21 |
22 | {categories.map((cat) => (
23 | onCategorySelect(cat.title)}
31 | />
32 | ))}
33 |
34 |
35 | );
36 | }
--------------------------------------------------------------------------------
/frontend/src/pages/apps-layout.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Outlet, useLocation } from "react-router-dom";
4 | import { ThemeToggle } from "../components/molecules/theme-toggle";
5 | import { LoginButton } from "../components/molecules/buttons/login-button";
6 | import { ROUTES } from "@/data/routes";
7 | import { BackNavigationProvider } from "@/context/BackNavigationContext";
8 | import { BackButton } from "@/components/molecules/BackButton";
9 | import { Header } from "@/components/atoms/header";
10 |
11 | export default function AppsLayout() {
12 | const location = useLocation();
13 |
14 | return (
15 |
16 |
17 |
}
19 | right={
20 |
21 |
22 |
23 |
24 | }
25 | showMainNav
26 | />
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/infra/nginx/vpn-index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | VPN Portal
6 |
7 |
37 |
38 |
39 |
40 |
VPN Portal
41 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/backend/modules/auth/cruds.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import or_, select
2 | from sqlalchemy.ext.asyncio import AsyncSession
3 |
4 | from backend.core.database.models import User
5 | from backend.modules.auth.schemas import UserSchema
6 |
7 |
8 | async def upsert_user(session: AsyncSession, user_schema: UserSchema) -> User:
9 | """
10 | Upsert a user into the database
11 | """
12 | # Query if the user already exists using the 'sub' field
13 | result = await session.execute(
14 | select(User).filter(or_(User.sub == user_schema.sub, User.email == user_schema.email))
15 | )
16 | user_db = result.scalars().first()
17 |
18 | if user_db:
19 | # User exists, update the user's information using unpacking
20 | for key, value in user_schema.model_dump().items():
21 | if key not in ["role", "scope"]: # Exclude role and scope from updates
22 | setattr(user_db, key, value)
23 | else:
24 | # User does not exist, create a new user
25 | user_db = User(**user_schema.model_dump())
26 | session.add(user_db)
27 |
28 | # Commit the session and refresh the user instance to get all the latest data
29 | await session.commit()
30 | await session.refresh(user_db)
31 |
32 | return user_db
33 |
--------------------------------------------------------------------------------
/frontend/src/features/events/utils/eventFormatters.ts:
--------------------------------------------------------------------------------
1 | import { format } from "date-fns";
2 | import type { EventPolicy } from "@/features/shared/campus/types";
3 |
4 | export const formatEventDate = (dateString: string) => {
5 | const date = new Date(dateString);
6 | return format(date, "d MMMM");
7 | };
8 |
9 | export const formatEventTime = (dateString: string) => {
10 | const date = new Date(dateString);
11 | return format(date, "p");
12 | };
13 |
14 | export const getPolicyDisplay = (policy: EventPolicy | string) => {
15 | switch (policy) {
16 | case "open":
17 | return "Open Entry";
18 | case "registration":
19 | return "Registration";
20 | default:
21 | return policy;
22 | }
23 | };
24 |
25 | export const getPolicyColor = (policy: EventPolicy | string) => {
26 | switch (policy) {
27 | case "open":
28 | return "bg-green-100 text-green-900 border-green-200 dark:bg-green-900/30 dark:text-green-200 dark:border-green-800";
29 | case "registration":
30 | return "bg-blue-100 text-blue-900 border-blue-200 dark:bg-blue-900/30 dark:text-blue-200 dark:border-blue-800";
31 | default:
32 | return "bg-gray-100 text-gray-900 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700";
33 | }
34 | };
35 |
36 |
--------------------------------------------------------------------------------
/frontend/src/features/media/index.ts:
--------------------------------------------------------------------------------
1 | // Unified Media System - Main Export File
2 |
3 | // Context
4 | export {
5 | UnifiedMediaProvider,
6 | useUnifiedMediaContext,
7 | type MediaConfig,
8 | type MediaState,
9 | type MediaActions,
10 | type UnifiedMediaContextType
11 | } from './context/UnifiedMediaContext';
12 |
13 | // Hooks
14 | export {
15 | useUnifiedMedia,
16 | type UnifiedMediaHookReturn
17 | } from './hooks/useUnifiedMedia';
18 |
19 | // Components
20 | export {
21 | UnifiedMediaUploadZone,
22 | type UnifiedMediaUploadZoneProps
23 | } from '@/components/organisms/media/UnifiedMediaUploadZone';
24 |
25 | // Configuration
26 | export {
27 | MEDIA_CONFIGS,
28 | getMediaConfig,
29 | createCustomMediaConfig,
30 | type MediaConfigKey
31 | } from './config/mediaConfigs';
32 |
33 | // Feature-specific components
34 | export { UnifiedEventMediaUpload } from '@/features/events/components/UnifiedEventMediaUpload';
35 |
36 | // Legacy exports for backward compatibility (deprecated)
37 | export { useMediaUpload } from './hooks/useMediaUpload';
38 | export { useMediaSelection } from './hooks/useMediaSelection';
39 | export { useMediaEdit } from './hooks/useMediaEdit';
40 |
41 | // Types
42 | export type { UploadMediaOptions } from './types/types';
43 |
--------------------------------------------------------------------------------
/backend/modules/notification/notification.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated, List
2 |
3 | from fastapi import APIRouter, Depends, Request
4 | from sqlalchemy.ext.asyncio import AsyncSession
5 |
6 | from backend.common.cruds import QueryBuilder
7 | from backend.common.dependencies import get_creds_or_401, get_db_session
8 | from backend.core.database.models.notification import Notification
9 | from backend.modules.notification import schemas
10 |
11 | router = APIRouter(tags=["Notifications"])
12 |
13 |
14 | @router.get("/notification", response_model=List[schemas.BaseNotification])
15 | async def get(
16 | request: Request,
17 | user: Annotated[tuple[dict, dict], Depends(get_creds_or_401)],
18 | page: int = 1,
19 | size: int = 10,
20 | session: AsyncSession = Depends(get_db_session),
21 | ) -> List[schemas.BaseNotification]:
22 | qb: QueryBuilder = QueryBuilder(session=session, model=Notification)
23 | notifications: List[Notification] = await (
24 | qb.base()
25 | .filter(Notification.receiver_sub == user[0]["sub"])
26 | .paginate(page=page, size=size)
27 | .order(Notification.created_at.desc())
28 | .all()
29 | )
30 | return [schemas.BaseNotification.model_validate(notification) for notification in notifications]
31 |
--------------------------------------------------------------------------------
/frontend/src/components/atoms/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as SwitchPrimitives from "@radix-ui/react-switch";
3 | import { cn } from "../../utils/utils";
4 |
5 | const Switch = React.forwardRef<
6 | React.ElementRef,
7 | React.ComponentPropsWithoutRef
8 | >(({ className, ...props }, ref) => (
9 |
17 |
22 |
23 | ));
24 | Switch.displayName = SwitchPrimitives.Root.displayName;
25 |
26 | export { Switch };
27 |
--------------------------------------------------------------------------------
/frontend/src/features/communities/hooks/use-community.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "../api/communitiesApi";
3 | import { useParams } from "react-router-dom";
4 | import { Community, CommunityPermissions } from "@/features/shared/campus/types";
5 |
6 | export const useCommunity = () => {
7 | const { id } = useParams<{ id: string }>();
8 |
9 | const { data, isPending, isLoading, isError } = useQuery({
10 | ...campuscurrentAPI.getCommunityQueryOptions(id || ""),
11 | enabled: !!id,
12 | // Normalize API response to a consistent shape
13 | select: (raw: any): { community: Community | null; permissions: CommunityPermissions | null } => {
14 | const community: Community | null = (raw?.community as Community) ?? (raw as Community) ?? null;
15 | const permissions: CommunityPermissions | null =
16 | (raw?.permissions as CommunityPermissions) ??
17 | ((community as any)?.permissions as CommunityPermissions) ??
18 | null;
19 | return { community, permissions };
20 | },
21 | });
22 |
23 | return {
24 | community: (data as any)?.community ?? null,
25 | permissions: (data as any)?.permissions ?? null,
26 | isPending,
27 | isLoading,
28 | isError,
29 | };
30 | };
31 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/auth-required-alert.tsx:
--------------------------------------------------------------------------------
1 | import { Alert, AlertTitle, AlertDescription } from "../atoms/alert";
2 | import { Button } from "../atoms/button";
3 | interface AuthRequiredAlertProps {
4 | description?: string;
5 | onClick: () => void;
6 | }
7 | export function AuthRequiredAlert({
8 | description = "create a listing.",
9 | onClick,
10 | }: AuthRequiredAlertProps) {
11 | return (
12 |
13 | Authentication Required
14 |
15 | You must be logged in to {description}
16 |
17 | Login
18 |
19 |
20 |
21 | );
22 | }
23 |
24 | export function TelegramRequiredAlert() {
25 | return (
26 |
30 |
31 | Telegram Required
32 |
33 |
34 | You need to link your Telegram account before selling items.
35 |
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/frontend/src/components/atoms/badge.tsx:
--------------------------------------------------------------------------------
1 | import type * as React from "react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 | import { cn } from "../../utils/utils";
4 |
5 | const badgeVariants = cva(
6 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
7 | {
8 | variants: {
9 | variant: {
10 | default:
11 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
12 | secondary:
13 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
14 | destructive:
15 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
16 | outline: "text-foreground",
17 | },
18 | },
19 | defaultVariants: {
20 | variant: "default",
21 | },
22 | },
23 | );
24 |
25 | export interface BadgeProps
26 | extends React.HTMLAttributes,
27 | VariantProps {}
28 |
29 | function Badge({ className, variant, ...props }: BadgeProps) {
30 | return (
31 |
32 | );
33 | }
34 |
35 | export { Badge, badgeVariants };
36 |
--------------------------------------------------------------------------------
/frontend/src/features/events/hooks/useEvents.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { campuscurrentAPI, TimeFilter } from "@/features/events/api/eventsApi";
3 | import { useState } from "react";
4 | import { usePageParam } from "@/hooks/usePageParam";
5 |
6 | export type UseEventsParams = {
7 | time_filter?: TimeFilter;
8 | start_date?: string;
9 | end_date?: string;
10 | registration_policy?: string | null;
11 | event_scope?: string | null;
12 | event_type?: string | null;
13 | event_status?: string | null;
14 | community_id?: number | null;
15 | creator_sub?: string | null;
16 | keyword?: string | null;
17 | size?: number;
18 | };
19 |
20 | export const useEvents = (params: UseEventsParams) => {
21 | const [page, setPage] = usePageParam();
22 | const [size, setSize] = useState(params.size ?? 12);
23 | const [keyword, setKeyword] = useState("");
24 |
25 | const { data, isLoading, isError } = useQuery(
26 | campuscurrentAPI.getEventsQueryOptions({
27 | ...params,
28 | page,
29 | size,
30 | keyword: keyword || null,
31 | }),
32 | );
33 |
34 | return {
35 | events: data || null,
36 | isLoading,
37 | isError,
38 | page,
39 | setPage,
40 | size,
41 | setSize,
42 | keyword,
43 | setKeyword,
44 | };
45 | };
46 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useGlobalSecondTicker.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | // Module-level singleton ticker to avoid multiple intervals across components
4 | let subscribers = new Set<() => void>();
5 | let intervalId: number | null = null;
6 |
7 | function notifyAll(): void {
8 | for (const subscriber of subscribers) {
9 | subscriber();
10 | }
11 | }
12 |
13 | function ensureRunning(): void {
14 | if (intervalId !== null) return;
15 | intervalId = window.setInterval(() => {
16 | notifyAll();
17 | }, 1000);
18 | }
19 |
20 | function ensureStopped(): void {
21 | if (intervalId === null) return;
22 | if (subscribers.size > 0) return;
23 | window.clearInterval(intervalId);
24 | intervalId = null;
25 | }
26 |
27 | export function useGlobalSecondTicker(): number {
28 | const [nowMs, setNowMs] = useState(Date.now());
29 |
30 | useEffect(() => {
31 | const handleTick = () => setNowMs(Date.now());
32 | subscribers.add(handleTick);
33 |
34 | // Start ticker if not running
35 | ensureRunning();
36 |
37 | // Emit an immediate tick so consumers get up-to-date value right away
38 | handleTick();
39 |
40 | return () => {
41 | subscribers.delete(handleTick);
42 | ensureStopped();
43 | };
44 | }, []);
45 |
46 | return nowMs;
47 | }
48 |
--------------------------------------------------------------------------------
/backend/modules/bot/cruds.py:
--------------------------------------------------------------------------------
1 | from sqlalchemy import select
2 | from sqlalchemy.ext.asyncio import AsyncSession
3 | from sqlalchemy.orm import selectinload
4 |
5 | from backend.core.database.models import Media, User
6 | from backend.core.database.models.common_enums import EntityType
7 |
8 |
9 | async def get_telegram_id(session: AsyncSession, sub: str) -> int | None:
10 | result = await session.execute(select(User.telegram_id).filter_by(sub=sub))
11 | user_id: int | None = result.scalars().first()
12 | return user_id
13 |
14 |
15 | async def check_existance_by_sub(session: AsyncSession, sub: str) -> bool:
16 | result = await session.execute(select(User.sub).filter_by(sub=sub))
17 | user = result.scalars().first()
18 | return True if user else False
19 |
20 |
21 | async def set_telegram_id(session: AsyncSession, sub: str, user_id: int) -> int:
22 | result = await session.execute(select(User).filter_by(sub=sub))
23 | user = result.scalars().first()
24 | user.telegram_id = user_id
25 | await session.commit()
26 | return user_id
27 |
28 |
29 | async def check_user_by_telegram_id(session: AsyncSession, user_id: int) -> bool:
30 | result = await session.execute(select(User.email).filter_by(telegram_id=user_id))
31 | user_email = result.scalars().first()
32 | return bool(user_email)
33 |
34 |
--------------------------------------------------------------------------------
/frontend/src/features/media/types/types.ts:
--------------------------------------------------------------------------------
1 | export interface Media {
2 | id: number;
3 | url: string;
4 | mime_type: string;
5 | entity_type: EntityType;
6 | entity_id: number;
7 | media_format: MediaFormat;
8 | media_order: number;
9 | }
10 |
11 |
12 | export enum MediaFormat {
13 | banner = "banner",
14 | carousel = "carousel",
15 | profile = "profile"
16 | }
17 |
18 | export enum EntityType {
19 | products = "products",
20 | community_events = "community_events",
21 | communities = "communities",
22 | community_posts = "community_posts",
23 | reviews = "reviews",
24 | community_comments = "community_comments"
25 | }
26 |
27 |
28 | export interface SignedUrlRequest {
29 | entity_type: EntityType;
30 | entity_id: number;
31 | media_format: MediaFormat;
32 | media_order: number;
33 | mime_type: string;
34 | content_type: string;
35 | }
36 |
37 | export interface SignedUrlResponse {
38 | filename: string;
39 | upload_url: string;
40 | entity_type: EntityType;
41 | entity_id: number;
42 | media_format: MediaFormat;
43 | media_order: number;
44 | mime_type: string;
45 | }
46 |
47 |
48 | export interface UploadMediaOptions {
49 | entity_type: EntityType;
50 | entityId: number;
51 | mediaFormat: MediaFormat;
52 | startOrder?: number;
53 | }
--------------------------------------------------------------------------------
/backend/core/database/models/__init__.py:
--------------------------------------------------------------------------------
1 | """Database models package."""
2 |
3 | __all__ = [
4 | "Base",
5 | "Community",
6 | "Event",
7 | "EventTag",
8 | "EventStatus",
9 | "EventScope",
10 | "EventType",
11 | "RegistrationPolicy",
12 | "CommunityMember",
13 | "CommunityRecruitmentStatus",
14 | "CommunityType",
15 | "CommunityCategory",
16 | "Media",
17 | "User",
18 | "UserRole",
19 | "UserScope",
20 | "GradeReport",
21 | "EventCollaborator",
22 | "Notification",
23 | "Course",
24 | "CourseItem",
25 | "Ticket",
26 | "Conversation",
27 | "Message",
28 | "MessageReadStatus",
29 | "DegreeAuditResult",
30 | ]
31 | from .base import Base
32 | from .community import (
33 | Community,
34 | CommunityCategory,
35 | CommunityMember,
36 | CommunityRecruitmentStatus,
37 | CommunityType,
38 | )
39 | from .events import (
40 | Event,
41 | EventCollaborator,
42 | EventScope,
43 | EventStatus,
44 | EventTag,
45 | EventType,
46 | RegistrationPolicy,
47 | )
48 | from .grade_report import Course, CourseItem, GradeReport
49 | from .media import Media
50 | from .notification import Notification
51 | from .user import User, UserRole, UserScope
52 | from .sgotinish import Ticket, Conversation, Message, MessageReadStatus
53 | from .degree_audit import DegreeAuditResult
--------------------------------------------------------------------------------
/frontend/src/components/atoms/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as PopoverPrimitive from "@radix-ui/react-popover";
3 |
4 | import { cn } from "@/utils/utils";
5 |
6 | const Popover = PopoverPrimitive.Root;
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger;
9 |
10 | const PopoverContent = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
14 |
15 |
25 |
26 | ));
27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
28 |
29 | export { Popover, PopoverTrigger, PopoverContent };
30 |
--------------------------------------------------------------------------------
/terraform/envs/production.tfvars:
--------------------------------------------------------------------------------
1 | # Production environment variables
2 | credentials_file = "./creds/production.json"
3 |
4 |
5 | # Boot disk strategy
6 | use_existing_boot_disk = false
7 | use_boot_snapshot = true
8 | boot_snapshot_name = "nuspace-boot-20250901-0012"
9 | boot_disk_size_gb = 30
10 |
11 | project_id = "nuspace2025"
12 | region = "europe-central2"
13 | zone = "europe-central2-a"
14 |
15 | vm_name = "nuspace-instance"
16 | vm_machine_type = "e2-medium"
17 | vm_instance_tags = ["https-server"]
18 | static_ip_name = "nuspace-static-ip"
19 |
20 | media_bucket_name = "nuspace-media"
21 | logs_bucket_name = "nuspace-logs"
22 |
23 | # Pub/Sub
24 | topic_name = "gcs-object-created"
25 | subscription_name = "gcs-object-created-sub"
26 | subscription_suffix = "prod"
27 |
28 | # Push subscription
29 | push_endpoint = "https://nuspace.kz/api/bucket/gcs-hook"
30 | push_auth_service_account_email = "nuspace-vm-sa@nuspace2025.iam.gserviceaccount.com"
31 | push_auth_audience = "nuspace"
32 |
33 | # Service accounts (IDs)
34 | vm_account_id = "nuspace-vm-sa"
35 | ansible_account_id = "nuspace-ansible-sa"
36 | signing_account_id = "nuspace-signing-sa"
37 |
38 | media_migration_region = "europe-central2"
39 |
40 | # WIF: GitHub repo allowed to impersonate (format: owner/repo)
41 | github_repository = "ulanpy/nuspace"
--------------------------------------------------------------------------------
/backend/modules/bot/middlewares/i18n.py:
--------------------------------------------------------------------------------
1 | import gettext
2 | import os
3 | from pathlib import Path
4 | from typing import Any, Awaitable, Callable
5 |
6 | from aiogram import BaseMiddleware
7 | from aiogram.types import CallbackQuery, Message, TelegramObject
8 | from redis.asyncio import Redis
9 |
10 |
11 | def get_translator(lang: str):
12 | LOCALES_DIR = os.path.join(Path(__file__).parent.parent, "locales")
13 | translator = gettext.translation("messages", localedir=LOCALES_DIR, languages=[lang])
14 | return translator.gettext
15 |
16 |
17 | class I18N(BaseMiddleware):
18 |
19 | async def __call__(
20 | self,
21 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
22 | event: TelegramObject,
23 | data: dict[str, Any],
24 | ) -> Any:
25 | user_id = None
26 |
27 | if isinstance(event, Message):
28 | user_id = event.from_user.id
29 | elif isinstance(event, CallbackQuery):
30 | user_id = event.from_user.id
31 |
32 | if not user_id:
33 | return await handler(event, data)
34 |
35 | user_id = event.from_user.id
36 | key = f"language:{user_id}"
37 | redis: Redis = data.get("redis")
38 | language: str = await redis.get(key) or "en"
39 | _ = get_translator(language)
40 | data["_"] = _
41 | return await handler(event, data)
42 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/theme-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Moon, Sun } from "lucide-react";
4 | import { useTheme } from "@/context/ThemeProviderContext";
5 | import { Switch } from "../atoms/switch";
6 |
7 | export function ThemeToggle() {
8 | const { theme, setTheme } = useTheme();
9 |
10 | const toggleTheme = () => {
11 | setTheme(theme === "light" ? "dark" : "light");
12 | };
13 |
14 | return (
15 |
16 |
22 |
28 |
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/frontend/src/features/communities/hooks/useDeleteCommunity.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "@/features/communities/api/communitiesApi";
3 | import { useToast } from "@/hooks/use-toast";
4 |
5 | export function useDeleteCommunity() {
6 | const { toast } = useToast();
7 | const queryClient = useQueryClient();
8 |
9 | const deleteCommunityMutation = useMutation({
10 | mutationFn: (id: string) => campuscurrentAPI.deleteCommunity(id),
11 | onSuccess: () => {
12 | queryClient.invalidateQueries({ queryKey: ["campusCurrent", "communities"] });
13 | toast({
14 | title: "Success",
15 | description: "Community deleted successfully!",
16 | });
17 | },
18 | onError: (error) => {
19 | console.error("Community deletion failed:", error);
20 | toast({
21 | title: "Error",
22 | description: "Failed to delete community",
23 | variant: "destructive",
24 | });
25 | },
26 | });
27 |
28 | const handleDelete = async (id: string) => {
29 | try {
30 | await deleteCommunityMutation.mutateAsync(id);
31 | } catch (error) {
32 | console.error("Community deletion failed:", error);
33 | throw error;
34 | }
35 | };
36 |
37 | return {
38 | handleDelete,
39 | isDeleting: deleteCommunityMutation.isPending,
40 | };
41 | }
42 |
43 |
--------------------------------------------------------------------------------
/frontend/src/utils/animationVariants.ts:
--------------------------------------------------------------------------------
1 | export const containerVariants = {
2 | hidden: { opacity: 0, y: 20 },
3 | visible: {
4 | opacity: 1,
5 | y: 0,
6 | transition: {
7 | type: "spring" as const,
8 | stiffness: 100,
9 | damping: 15,
10 | staggerChildren: 0.1
11 | }
12 | }
13 | };
14 |
15 | export const itemVariants = {
16 | hidden: { opacity: 0, y: 10 },
17 | visible: {
18 | opacity: 1,
19 | y: 0,
20 | transition: {
21 | type: "spring" as const,
22 | stiffness: 100,
23 | damping: 15
24 | }
25 | }
26 | };
27 |
28 | export const fieldVariants = {
29 | hidden: { opacity: 0, y: 10 },
30 | visible: {
31 | opacity: 1,
32 | y: 0,
33 | transition: {
34 | type: "spring" as const,
35 | stiffness: 100,
36 | damping: 15
37 | }
38 | }
39 | };
40 |
41 | export const formVariants = {
42 | hidden: { opacity: 0, y: 20 },
43 | visible: {
44 | opacity: 1,
45 | y: 0,
46 | transition: {
47 | type: "spring" as const,
48 | stiffness: 100,
49 | damping: 15,
50 | staggerChildren: 0.1
51 | }
52 | }
53 | };
54 |
55 | export const sectionVariants = {
56 | hidden: { opacity: 0, y: 10 },
57 | visible: {
58 | opacity: 1,
59 | y: 0,
60 | transition: {
61 | type: "spring" as const,
62 | stiffness: 100,
63 | damping: 15
64 | }
65 | }
66 | };
--------------------------------------------------------------------------------
/backend/common/schemas.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from backend.core.configs.config import Config
4 | from backend.core.database.models.common_enums import EntityType
5 | from backend.core.database.models.media import MediaFormat
6 | from google.auth.credentials import Credentials
7 | from google.cloud import storage
8 | from httpx import AsyncClient
9 | from redis.asyncio import Redis
10 | from faststream.rabbit import RabbitBroker
11 | from pydantic import BaseModel
12 |
13 |
14 | class MediaResponse(BaseModel):
15 | id: int
16 | url: str
17 | mime_type: str
18 | entity_type: EntityType
19 | entity_id: int
20 | media_format: MediaFormat
21 | media_order: int
22 |
23 |
24 | class ShortUserResponse(BaseModel):
25 | sub: str
26 | name: str
27 | surname: str
28 | picture: str
29 |
30 | class Config:
31 | from_attributes = True
32 |
33 |
34 | class ResourcePermissions(BaseModel):
35 | can_edit: bool = False
36 | can_delete: bool = False
37 | editable_fields: List[str] = []
38 |
39 |
40 | class Infra(BaseModel):
41 | """Infrastructure dependencies for FastAPI instance"""
42 |
43 | meilisearch_client: AsyncClient
44 | storage_client: storage.Client
45 | config: Config
46 | signing_credentials: Credentials | None = None
47 | redis: Redis
48 | broker: RabbitBroker
49 | class Config:
50 | arbitrary_types_allowed = True
51 |
--------------------------------------------------------------------------------
/backend/modules/bot/routes/user/private/__init__.py:
--------------------------------------------------------------------------------
1 | from aiogram import F, Router
2 | from aiogram.enums.chat_type import ChatType
3 |
4 |
5 | from backend.modules.bot.routes.user.private.messages.start import router as start
6 | from backend.modules.bot.routes.user.private.messages.start_deeplink import router as start_deeplink
7 | from backend.modules.bot.routes.user.private.messages.student_validator import router as student_validator
8 | from backend.modules.bot.routes.user.private.callback.confirmation import router as confirmation
9 |
10 |
11 | def setup_private_callback_router() -> Router:
12 | router: Router = Router(name="Private callback router")
13 | router.include_router(confirmation)
14 | return router
15 |
16 | def setup_private_message_router() -> Router:
17 | # ORDER MATTERS
18 | router: Router = Router(name="Private message router")
19 | router.include_router(start_deeplink)
20 | router.include_router(start)
21 | router.include_router(student_validator)
22 | return router
23 |
24 | def setup_private_routers():
25 | router: Router = Router(name="Private router")
26 | router.message.filter(F.chat.type == ChatType.PRIVATE)
27 |
28 | callback_router: Router = setup_private_callback_router()
29 | message_router: Router = setup_private_message_router()
30 | router.include_router(callback_router)
31 | router.include_router(message_router)
32 | return router
33 |
--------------------------------------------------------------------------------
/backend/modules/courses/registrar/priority_sync_worker.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import json
3 | import os
4 | import sys
5 | from pathlib import Path
6 | from typing import Sequence
7 |
8 | import httpx
9 |
10 | from backend.modules.courses.registrar.parsers.priority_parser import parse_pdf
11 | from backend.modules.courses.registrar.priority_sync import PRIORITY_PDF_URL
12 |
13 |
14 | async def main() -> None:
15 | pdf_url = os.environ.get("PRIORITY_SYNC__PDF_URL", PRIORITY_PDF_URL)
16 | output_path = os.environ.get("PRIORITY_SYNC__OUTPUT_PATH")
17 |
18 | if not output_path:
19 | raise SystemExit("PRIORITY_SYNC__OUTPUT_PATH env var is required")
20 |
21 | content = await _download(pdf_url)
22 | loop = asyncio.get_running_loop()
23 | documents = await loop.run_in_executor(None, parse_pdf, content)
24 |
25 | Path(output_path).write_text(json.dumps(documents), encoding="utf-8")
26 |
27 |
28 | async def _download(url: str) -> bytes:
29 | async with httpx.AsyncClient(timeout=60.0, verify=False) as client:
30 | response = await client.get(url)
31 | response.raise_for_status()
32 | return response.content
33 |
34 |
35 | if __name__ == "__main__":
36 | try:
37 | asyncio.run(main())
38 | except Exception as exc: # pragma: no cover - surfaced in parent process
39 | print(f"Priority sync worker failed: {exc}", file=sys.stderr)
40 | raise
41 |
42 |
43 |
--------------------------------------------------------------------------------
/backend/modules/sgotinish/messages/dependencies.py:
--------------------------------------------------------------------------------
1 | from backend.core.database.models.sgotinish import Message
2 | from backend.common.cruds import QueryBuilder
3 | from backend.common.dependencies import get_db_session
4 | from fastapi import Depends, HTTPException, status
5 | from sqlalchemy.ext.asyncio import AsyncSession
6 | from sqlalchemy.orm import selectinload
7 | from backend.core.database.models.sgotinish import Conversation
8 |
9 |
10 |
11 | async def message_exists_or_404(
12 | message_id: int,
13 | db_session: AsyncSession = Depends(get_db_session),
14 | ) -> Message:
15 | """
16 | Dependency to validate that a message exists and return it.
17 |
18 | Args:
19 | message_id: ID of the message to validate
20 | db_session: Database session
21 |
22 | Returns:
23 | Message: The message if found
24 |
25 | Raises:
26 | HTTPException: 404 if message not found
27 | """
28 | qb = QueryBuilder(session=db_session, model=Message)
29 | message = (
30 | await qb.base()
31 | .eager(Message.conversation, Message.sender)
32 | .option(selectinload(Message.conversation).selectinload(Conversation.ticket))
33 | .filter(Message.id == message_id)
34 | .first()
35 | )
36 | if not message:
37 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Message not found")
38 | return message
39 |
40 |
--------------------------------------------------------------------------------
/frontend/Dockerfile_vite:
--------------------------------------------------------------------------------
1 | # Use official Node.js image with Alpine Linux
2 | FROM node:20-alpine
3 |
4 | # Install bash and other essential tools for development
5 | RUN apk add --no-cache bash curl git jq
6 |
7 | # Set working directory in container
8 | WORKDIR /nuros
9 |
10 | # Copy only package.json and package-lock.json for caching dependencies
11 | COPY frontend/package*.json ./
12 | # COPY frontend/package-lock.json ./
13 |
14 | # Clean install dependencies (force rebuild for container architecture)
15 | RUN npm install
16 |
17 | # Copy the rest of the project files
18 | COPY frontend .
19 |
20 | # Script to resolve quick tunnel URL from cloudflared metrics and export for Vite
21 | RUN printf '%s\n' '#!/bin/sh' \
22 | 'set -e' \
23 | 'cd /nuros' \
24 | 'METRICS="http://cloudflared:2000/metrics"' \
25 | 'for i in 1 2 3 4 5; do' \
26 | ' URL=$(curl -fsS "$METRICS" | grep -Eo "https://[a-z0-9-]+\\.trycloudflare\\.com" | head -n1 || true)' \
27 | ' if [ -n "$URL" ]; then echo "$URL"; break; fi' \
28 | ' sleep 2' \
29 | 'done' \
30 | 'if [ -z "$URL" ]; then URL="http://localhost:5173"; fi' \
31 | 'export CLOUDFLARED_TUNNEL_URL="$URL"' \
32 | 'echo "Using CLOUDFLARED_TUNNEL_URL=$CLOUDFLARED_TUNNEL_URL"' \
33 | 'exec npm run dev -- --host' > /usr/local/bin/start-frontend.sh \
34 | && chmod +x /usr/local/bin/start-frontend.sh
35 |
36 | # Start the Vite development server
37 | CMD ["/usr/local/bin/start-frontend.sh"]
38 |
--------------------------------------------------------------------------------
/backend/core/database/manager.py:
--------------------------------------------------------------------------------
1 | from typing import AsyncGenerator
2 |
3 | from backend.core.configs.config import config
4 | from backend.core.database.models import Base
5 | # from backend.core.database.models import Base
6 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
7 |
8 |
9 | class AsyncDatabaseManager:
10 | def __init__(self):
11 | self.async_engine = create_async_engine(
12 | config.DATABASE_URL,
13 | query_cache_size=1200,
14 | pool_size=20,
15 | max_overflow=200,
16 | future=True,
17 | echo=False,
18 | )
19 | self.async_session_maker = async_sessionmaker(
20 | bind=self.async_engine,
21 | expire_on_commit=False,
22 | )
23 |
24 | # === Deprecated. Will be removed starting from October 2025 ===
25 | async def create_all_tables(self) -> None:
26 | async with self.async_engine.begin() as conn:
27 | await conn.run_sync(Base.metadata.create_all)
28 | await self.async_engine.dispose()
29 |
30 | # this function returns async session used in fastapi dependency injections
31 | async def get_async_session(self) -> AsyncGenerator[AsyncSession, None]:
32 | async with self.async_session_maker() as session:
33 | try:
34 | yield session
35 | finally:
36 | await session.close()
37 |
--------------------------------------------------------------------------------
/terraform/envs/staging.tfvars:
--------------------------------------------------------------------------------
1 | # Staging environment variables
2 | credentials_file = "./creds/staging.json"
3 |
4 | # Boot disk strategy
5 | use_existing_boot_disk = false
6 | use_boot_snapshot = false
7 | boot_snapshot_name = null
8 | boot_disk_size_gb = 30
9 | boot_disk_type = "pd-standard"
10 |
11 |
12 | project_id = "nuspace-staging"
13 | region = "europe-central2"
14 | zone = "europe-central2-a"
15 |
16 | vm_name = "nuspace-instance"
17 | vm_machine_type = "e2-medium"
18 | vm_instance_tags = ["https-server"]
19 | static_ip_name = "nuspace-static-ip"
20 |
21 | media_bucket_name = "nuspace-media-staging"
22 | logs_bucket_name = "nuspace-logs-staging"
23 |
24 | # Pub/Sub
25 | topic_name = "gcs-object-created"
26 | subscription_name = "gcs-object-created-sub"
27 | subscription_suffix = "staging"
28 |
29 | # Push subscription
30 | push_endpoint = "https://stage.nuspace.kz/api/bucket/gcs-hook"
31 | push_auth_service_account_email = "nuspace-vm-sa@nuspace-staging.iam.gserviceaccount.com"
32 | push_auth_audience = "nuspace"
33 |
34 | # Service accounts (IDs)
35 | vm_account_id = "nuspace-vm-sa"
36 | ansible_account_id = "nuspace-ansible-sa"
37 | signing_account_id = "nuspace-signing-sa"
38 |
39 |
40 | media_migration_region = "europe-central2"
41 |
42 | # WIF: GitHub repo allowed to impersonate (format: owner/repo)
43 | github_repository = "ulanpy/nuspace"
--------------------------------------------------------------------------------
/backend/modules/__init__.py:
--------------------------------------------------------------------------------
1 | from typing import List
2 |
3 | from fastapi import APIRouter
4 |
5 | from backend.modules.bot.bot import web_router
6 |
7 | from .auth import auth
8 | from .campuscurrent.communities import communities
9 | from .campuscurrent.events import events
10 | from .campuscurrent.profile import profile
11 | from .google_bucket import google_bucket
12 | from .courses.courses import courses
13 | from .courses.planner import planner
14 | from .courses.statistics import statistics
15 | from .courses.templates import templates
16 | from .courses.degree_audit import api as degree_audit
17 | from .notification import notification
18 | from .search import search
19 | from .sgotinish.tickets import tickets, delegation
20 | from .sgotinish.conversations import conversations
21 | from .sgotinish.messages import messages
22 | from .announcements import router as announcements_router
23 | # Import all routers from the routes directory
24 |
25 | routers: List[APIRouter] = [
26 | auth.router,
27 | communities.router,
28 | events.router,
29 | profile.router,
30 | search.router,
31 | google_bucket.router,
32 | web_router,
33 | courses.router,
34 | planner.router,
35 | statistics.router,
36 | templates.router,
37 | degree_audit.router,
38 | notification.router,
39 | tickets.router,
40 | conversations.router,
41 | messages.router,
42 | delegation.router,
43 | announcements_router,
44 | ]
45 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/DeleteConfirmation.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from '@/components/atoms/button';
2 |
3 | interface DeleteConfirmationProps {
4 | title: string;
5 | isVisible: boolean;
6 | isDeleting: boolean;
7 | onCancel: () => void;
8 | onConfirm: () => void;
9 | }
10 |
11 | export function DeleteConfirmation({
12 | title,
13 | isVisible,
14 | isDeleting,
15 | onCancel,
16 | onConfirm
17 | }: DeleteConfirmationProps) {
18 | if (!isVisible) {
19 | return null;
20 | }
21 |
22 | return (
23 |
24 |
25 | Delete {title}
26 |
27 |
28 | Are you sure you want to delete this {title.toLowerCase()}? This action cannot be undone.
29 |
30 |
31 |
37 | Cancel
38 |
39 |
45 | {isDeleting ? "Deleting..." : `Delete ${title}`}
46 |
47 |
48 |
49 | );
50 | }
--------------------------------------------------------------------------------
/backend/main.py:
--------------------------------------------------------------------------------
1 | from fastapi import FastAPI
2 | from fastapi.middleware.cors import CORSMiddleware
3 | from starlette.middleware.sessions import SessionMiddleware
4 | from fastapi.responses import ORJSONResponse
5 |
6 | from backend.core.configs.config import config
7 | from backend.lifespan import lifespan
8 |
9 | # Import both the instrumentor and the metrics_app
10 | from backend.middlewares.prometheus_metrics import instrument_app, metrics_app
11 |
12 | app = FastAPI(
13 | debug=True,
14 | lifespan=lifespan,
15 | default_response_class=ORJSONResponse, # for performance
16 | root_path="/api",
17 | docs_url="/docs" if config.IS_DEBUG else None,
18 | redoc_url="/redoc" if config.IS_DEBUG else None,
19 | openapi_url="/openapi.json" if config.IS_DEBUG else None,
20 | title="NU Space API",
21 | description=" Nuspace.kz is a SuperApp for NU students that streamlines communication and "
22 | "replaces disorganized Telegram chats with a more reliable solution. "
23 | "[Project Github](https://github.com/ulanpy/nuspace). ",
24 | version="1.0.4",
25 | )
26 |
27 | app.mount("/metrics", metrics_app)
28 |
29 | app.add_middleware(
30 | CORSMiddleware,
31 | allow_origins=["*"] if config.IS_DEBUG else config.ORIGINS,
32 | allow_credentials=True,
33 | allow_methods=["*"],
34 | allow_headers=["*"],
35 | )
36 | app.add_middleware(SessionMiddleware, secret_key=config.SESSION_MIDDLEWARE_KEY)
37 |
38 | instrument_app(app)
39 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/combined-search.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { SearchInput } from "./search-input";
3 | import { ConditionDropdown } from "./condition-dropdown";
4 | import { PreSearchedItem } from "@/types/search";
5 |
6 | interface CombinedSearchProps {
7 | // Search props
8 | inputValue: string;
9 | setInputValue: (value: string) => void;
10 | preSearchedItems: PreSearchedItem[] | null;
11 | handleSearch: (query: string) => void;
12 | setKeyword: (keyword: string) => void;
13 |
14 | // Condition props
15 | conditions: string[];
16 | selectedCondition: string;
17 | setSelectedCondition: (condition: string) => void;
18 | }
19 |
20 | export function CombinedSearch({
21 | inputValue,
22 | setInputValue,
23 | preSearchedItems,
24 | handleSearch,
25 | setKeyword,
26 | conditions,
27 | selectedCondition,
28 | setSelectedCondition,
29 | }: CombinedSearchProps) {
30 | return (
31 |
32 |
37 |
45 |
46 | );
47 | }
--------------------------------------------------------------------------------
/frontend/src/features/communities/hooks/use-communities.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { campuscurrentAPI } from "../api/communitiesApi";
3 | import { useState, useEffect } from "react";
4 | import { usePageParam } from "@/hooks/usePageParam";
5 | import { Community } from "@/features/shared/campus/types";
6 | import { useLocation } from "react-router-dom";
7 | import { getSearchParamFromURL } from "@/utils/search-params";
8 |
9 | export const useCommunities = (options?: { category?: string | null; recruitment_status?: 'open' | 'closed' | null }) => {
10 | const [page, setPage] = usePageParam();
11 | const [size, setSize] = useState(12);
12 | const [keyword, setKeyword] = useState("");
13 | const location = useLocation();
14 |
15 | useEffect(() => {
16 | const text = getSearchParamFromURL(location.search, "text");
17 | setKeyword(text);
18 | }, [location.search]);
19 |
20 | const { data, isLoading, isError } = useQuery>(
21 | campuscurrentAPI.getCommunitiesQueryOptions({
22 | page,
23 | size,
24 | keyword: keyword || null,
25 | category: options?.category ?? null,
26 | recruitment_status: options?.recruitment_status ?? null,
27 | }),
28 | );
29 |
30 | return {
31 | communities: data || null,
32 | isLoading,
33 | isError,
34 | page,
35 | setPage,
36 | size,
37 | setSize,
38 | keyword,
39 | setKeyword,
40 | };
41 | };
42 |
--------------------------------------------------------------------------------
/frontend/src/components/layout/PublicRoute.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Navigate, Outlet, useLocation } from "react-router-dom";
4 | import { useUser } from "@/hooks/use-user";
5 | import { ROUTES } from "@/data/routes";
6 |
7 | interface LocationState {
8 | from?: string;
9 | }
10 |
11 |
12 | export function PublicRoute() {
13 | const { user, isLoading, isFetching, isSuccess, isError } = useUser();
14 | const location = useLocation();
15 | const state = location.state as LocationState | null;
16 |
17 |
18 | // Avoid flashing the landing page while auth status is loading
19 | if (isLoading || isFetching) {
20 | return (
21 |
22 |
23 |
24 |
Loading...
25 |
26 |
27 | );
28 | }
29 |
30 | // If authenticated, redirect to original destination or Announcements
31 | if (isSuccess && !isError && user) {
32 | const redirectTo = state?.from || ROUTES.ANNOUNCEMENTS;
33 | return ;
34 | }
35 |
36 | // User is not authenticated, render the public content
37 | return ;
38 | }
39 |
40 | export default PublicRoute;
41 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/about/report-card.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent } from "@/components/atoms/card";
2 | import { useTheme } from "@/context/ThemeProviderContext";
3 | import { FaHeadset, FaTelegram } from "react-icons/fa";
4 |
5 | export function ReportCard() {
6 | const { theme } = useTheme();
7 | const isDark = theme === "dark";
8 | return (
9 |
14 |
15 |
16 |
17 |
18 | Need Help?
19 |
22 | Found a bug or having issues? Reach out directly
23 | for quick assistance.
24 |
25 |
29 |
30 | Contact via Telegram
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/frontend/src/assets/svg/Vector.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/category-slider.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from "react-router-dom";
2 | import { CategoryGrid } from "./category-grid";
3 |
4 | interface CategorySliderProps {
5 | categories: { title: string; icon?: JSX.Element}[];
6 | selectedCategory: string | "";
7 | setPage?: (page: number) => void;
8 | setSelectedCategory: (category: string) => void;
9 | setInputValue?: (value: string) => void;
10 | setSelectedCondition?: (condition: string) => void;
11 | }
12 |
13 | export function CategorySlider({
14 | categories,
15 | selectedCategory,
16 | setPage,
17 | setSelectedCategory,
18 | setInputValue,
19 | setSelectedCondition,
20 | }: CategorySliderProps) {
21 | const navigate = useNavigate();
22 |
23 | const handleCategorySelect = (title: string) => {
24 | setSelectedCategory(title);
25 | setPage?.(1);
26 | setInputValue?.("");
27 | setSelectedCondition?.("All Conditions");
28 |
29 | navigate(`${window.location.pathname}?category=${title.toLowerCase()}`);
30 | };
31 |
32 | return (
33 | <>
34 | {categories?.length > 0 && (
35 |
44 | )}
45 | >
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/backend/core/database/models/media.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 | from enum import Enum as PyEnum
3 |
4 | from sqlalchemy import BigInteger, Column, DateTime, Integer
5 | from sqlalchemy import Enum as SQLEnum
6 | from sqlalchemy.orm import Mapped, mapped_column
7 |
8 | from .base import Base
9 | from .common_enums import EntityType
10 |
11 |
12 | class MediaFormat(PyEnum):
13 | banner = "banner"
14 | carousel = "carousel"
15 | profile = "profile"
16 |
17 |
18 | # Mapped[dtype] defaults parameters: nullable=False, unique=True, primary_key=False
19 | class Media(Base):
20 | __tablename__ = "media"
21 | id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
22 | name: Mapped[str] = mapped_column(nullable=False, index=True, unique=True)
23 | mime_type: Mapped[str] = mapped_column(nullable=False, unique=False)
24 | entity_type: Mapped[EntityType] = mapped_column(
25 | SQLEnum(EntityType, name="entity_type"), nullable=False, index=True
26 | )
27 | entity_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True, unique=False)
28 | media_format: Mapped[MediaFormat] = mapped_column(
29 | SQLEnum(MediaFormat, name="media_format"), nullable=False, index=True
30 | )
31 | media_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
32 | created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
33 | updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
34 |
--------------------------------------------------------------------------------
/frontend/src/components/molecules/login-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { Button } from "../../components/atoms/button";
5 | import { useUser } from "../../hooks/use-user";
6 | import { Modal } from "../../components/atoms/modal";
7 |
8 | interface LoginModalProps {
9 | isOpen: boolean;
10 | onClose: () => void;
11 | onSuccess: () => void;
12 | title: string;
13 | message: string;
14 | }
15 |
16 | export const LoginModal = ({
17 | isOpen,
18 | onClose,
19 | onSuccess,
20 | title,
21 | message,
22 | }: LoginModalProps) => {
23 | const { user, login } = useUser();
24 | const [isLoggingIn, setIsLoggingIn] = useState(false);
25 |
26 | const handleLogin = () => {
27 | setIsLoggingIn(true);
28 | login();
29 | // In a real app, we would wait for the login to complete
30 | // For now, we'll just simulate it
31 | setTimeout(() => {
32 | setIsLoggingIn(false);
33 | onSuccess();
34 | }, 1000);
35 | };
36 |
37 | return (
38 |
44 |
45 |
46 | Cancel
47 |
48 |
49 | {isLoggingIn ? "Logging in..." : "Login"}
50 |
51 |
52 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/backend/modules/courses/registrar/tests/test_priority_parser.py:
--------------------------------------------------------------------------------
1 | from backend.modules.courses.registrar.parsers import priority_parser
2 |
3 |
4 | def _fake_table_row(
5 | index: str,
6 | abbr: str,
7 | title: str = "Course Title",
8 | prereq: str = "Prereq",
9 | ) -> list[str]:
10 | return [
11 | index,
12 | abbr,
13 | title,
14 | "", # credits col placeholder
15 | "", # ects
16 | prereq,
17 | "", # coreq
18 | "", # antireq
19 | "", # priority 1
20 | "", # priority 2
21 | "", # priority 3
22 | "", # priority 4
23 | ]
24 |
25 |
26 | def test_parse_pdf_skips_explicit_header(monkeypatch):
27 | table = [
28 | ["#", "Abbr", "Title", "Cr", "ECTS", "Pre", "Co", "Anti", "P1", "P2", "P3", "P4"],
29 | _fake_table_row("1", "MATH 162"),
30 | ]
31 |
32 | monkeypatch.setattr(priority_parser, "iter_tables", lambda _: [table])
33 |
34 | rows = priority_parser.parse_pdf(b"fake")
35 | assert len(rows) == 1
36 | assert rows[0]["abbr"] == "MATH 162"
37 |
38 |
39 | def test_parse_pdf_keeps_first_row_when_no_header(monkeypatch):
40 | table = [
41 | _fake_table_row("1", "MATH 162"),
42 | _fake_table_row("2", "MATH 263"),
43 | ]
44 |
45 | monkeypatch.setattr(priority_parser, "iter_tables", lambda _: [table])
46 |
47 | rows = priority_parser.parse_pdf(b"fake")
48 | assert rows[0]["abbr"] == "MATH 162"
49 | assert rows[1]["abbr"] == "MATH 263"
50 |
51 |
52 |
--------------------------------------------------------------------------------
/backend/modules/notification/tasks.py:
--------------------------------------------------------------------------------
1 | from aiogram import Bot
2 | from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter
3 | from faststream.rabbit import RabbitBroker
4 | from faststream.rabbit.annotations import RabbitMessage
5 |
6 | from backend.core.configs.config import config
7 | from backend.modules.notification import schemas
8 | from backend.modules.notification.rate_limiter import TelegramRateLimiter
9 |
10 | broker = RabbitBroker(config.CELERY_BROKER_URL) # declared only once
11 |
12 | rate_limiter = TelegramRateLimiter(global_rate_per_sec=30, per_chat_min_interval=1.0)
13 |
14 |
15 | @broker.subscriber("notifications")
16 | async def process_notification(notification: schemas._RequestNotification, msg: RabbitMessage):
17 | from backend.modules.bot.keyboards.kb import kb_url
18 |
19 | if not notification.switch:
20 | return
21 | if not notification.tg_id:
22 | await msg.ack()
23 | return
24 | bot = Bot(token=config.TELEGRAM_BOT_TOKEN)
25 | message = f"{notification.title}\n\n{notification.message}"
26 | try:
27 | await rate_limiter.wait(notification.tg_id)
28 | await bot.send_message(
29 | notification.tg_id,
30 | message,
31 | reply_markup=kb_url(notification.url) if notification.url else None,
32 | )
33 | await msg.ack()
34 | except TelegramForbiddenError:
35 | await msg.reject()
36 | except TelegramRetryAfter:
37 | await msg.nack()
38 | finally:
39 | await bot.session.close()
40 |
--------------------------------------------------------------------------------
/frontend/src/components/organisms/admin/stat-card.tsx:
--------------------------------------------------------------------------------
1 | import { Card, CardContent } from "@/components/atoms/card";
2 | import { ArrowUp } from "lucide-react";
3 |
4 | interface StatCardProps {
5 | title: string;
6 | value: string;
7 | icon: React.ReactNode;
8 | change: string;
9 | trend: "up" | "down";
10 | }
11 |
12 | export const StatCard = ({
13 | title,
14 | value,
15 | icon,
16 | change,
17 | trend,
18 | }: StatCardProps) => {
19 | return (
20 |
21 |
22 |
23 |
24 |
{title}
25 |
{value}
26 |
27 |
{icon}
28 |
29 |
30 |
37 | {trend === "up" ? (
38 |
39 | ) : (
40 |
41 | )}
42 | {change}
43 |
44 |
vs last month
45 |
46 |
47 |
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/backend/modules/campuscurrent/events/dependencies.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated
2 |
3 | from fastapi import Depends, HTTPException, status
4 | from sqlalchemy.ext.asyncio import AsyncSession
5 |
6 | from backend.common.cruds import QueryBuilder
7 | from backend.common.dependencies import get_creds_or_401, get_db_session
8 | from backend.core.database.models import Event
9 | from backend.core.database.models.user import User
10 | from backend.modules.campuscurrent.events import schemas
11 |
12 |
13 | async def event_exists_or_404(
14 | event_id: int,
15 | db_session: AsyncSession = Depends(get_db_session),
16 | ) -> Event:
17 | qb = QueryBuilder(session=db_session, model=Event)
18 | event = await qb.base().filter(Event.id == event_id).first()
19 | if event is None:
20 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found")
21 | return event
22 |
23 |
24 | async def user_exists_or_404(
25 | event_data: schemas.EventCreateRequest,
26 | user: Annotated[tuple[dict, dict], Depends(get_creds_or_401)],
27 | db_session: AsyncSession = Depends(get_db_session),
28 | ) -> User:
29 | qb = QueryBuilder(session=db_session, model=User)
30 | if event_data.creator_sub == "me":
31 | db_user = await qb.base().filter(User.sub == user[0]["sub"]).first()
32 | else:
33 | db_user = await qb.base().filter(User.sub == event_data.creator_sub).first()
34 | if db_user is None:
35 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
36 | return db_user
37 |
--------------------------------------------------------------------------------
/frontend/src/layouts/LoggedInLayout.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Outlet } from "react-router-dom";
4 | import { MediaUploadProvider } from "@/context/MediaUploadContext";
5 | import { MediaEditProvider } from "@/context/MediaEditContext";
6 | import { BackNavigationProvider } from "@/context/BackNavigationContext";
7 | import { Sidebar } from "@/components/layout/Sidebar";
8 | import { Toasts } from "@/components/atoms/toast";
9 |
10 | export function LoggedInLayout() {
11 | return (
12 |
13 |
14 |
15 |
16 | {/* Sidebar component handles both desktop fixed sidebar and mobile hamburger/sheet */}
17 |
18 |
19 | {/* Main content area with left margin on desktop to account for sidebar */}
20 |
21 |
22 |
23 |
24 |
25 |
26 | {/* Toast notifications */}
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
35 | export default LoggedInLayout;
36 |
--------------------------------------------------------------------------------
/backend/core/database/models/common_enums.py:
--------------------------------------------------------------------------------
1 | from enum import Enum as PyEnum
2 |
3 |
4 | class EntityType(str, PyEnum):
5 | """Enum representing different types of entities(db tables names)
6 | Add new table names here to extend
7 |
8 | ⚠️ IMPORTANT: When adding new values to this enum:
9 | 1. Add the new value to this Python enum class
10 | 2. Create a new Alembic migration manually (alembic revision -m "add_new_entity_type")
11 | 3. In the migration's upgrade() function, add: op.execute("ALTER TYPE entity_type ADD VALUE 'your_new_value'")
12 | 4. Run the migration: alembic upgrade head
13 |
14 | Alembic cannot auto-detect enum value changes, so manual migration is required!
15 | """
16 |
17 | community_events = "community_events"
18 | communities = "communities"
19 | grade_reports = "grade_reports"
20 | courses = "courses"
21 | tickets = "tickets"
22 | messages = "messages"
23 |
24 |
25 | class NotificationType(str, PyEnum):
26 | """Enum representing different types of notifications
27 | Add new values here to extend
28 |
29 | ⚠️ IMPORTANT: When adding new values to this enum:
30 | 1. Add the new value to this Python enum class
31 | 2. Create a new Alembic migration manually (alembic revision -m "add_new_notification_type")
32 | 3. In the migration's upgrade() function, add: op.execute("ALTER TYPE notification_type ADD VALUE 'your_new_value'")
33 | 4. Run the migration: alembic upgrade head
34 |
35 | Alembic cannot auto-detect enum value changes, so manual migration is required!
36 | """
37 |
38 | info = "info"
39 |
--------------------------------------------------------------------------------
/backend/lifespan.py:
--------------------------------------------------------------------------------
1 | from contextlib import asynccontextmanager
2 |
3 | from fastapi import FastAPI
4 | from google.auth.credentials import Credentials
5 |
6 | from backend.app_state.bot import cleanup_bot, setup_bot
7 | from backend.app_state.db import cleanup_db, setup_db
8 | from backend.app_state.gcp import setup_gcp
9 | from backend.app_state.meilisearch import cleanup_meilisearch, setup_meilisearch
10 |
11 | from backend.app_state.rbq import cleanup_rbq, setup_rbq
12 | from backend.app_state.redis import cleanup_redis, setup_redis
13 | from backend.core.configs.config import Config
14 | from backend.modules import routers
15 | from backend.modules.auth.app_token import AppTokenManager
16 | from backend.modules.auth.keycloak_manager import KeyCloakManager
17 |
18 |
19 | @asynccontextmanager
20 | async def lifespan(app: FastAPI):
21 | try:
22 | app.state.kc_manager = KeyCloakManager() # type: ignore
23 | app.state.config = Config() # type: ignore
24 | app.state.app_token_manager = AppTokenManager()
25 | app.state.signing_credentials: Credentials | None = None
26 | setup_gcp(app)
27 | await setup_rbq(app)
28 | await setup_db(app)
29 | await setup_redis(app)
30 | await setup_meilisearch(app)
31 |
32 | await setup_bot(app)
33 |
34 | for router in routers:
35 | app.include_router(router)
36 | yield
37 |
38 | finally:
39 | await cleanup_rbq(app)
40 | await cleanup_bot(app)
41 | await cleanup_meilisearch(app)
42 | await cleanup_redis(app)
43 | await cleanup_db(app)
44 |
--------------------------------------------------------------------------------
/frontend/src/features/events/hooks/useInfiniteEvents.ts:
--------------------------------------------------------------------------------
1 | import { useInfiniteScroll, useInfiniteScrollWithWindow } from '@/hooks/useInfiniteScroll';
2 | import { Event } from '@/features/shared/campus/types';
3 | import * as Routes from '@/data/routes';
4 | import { TimeFilter } from '@/features/events/api/eventsApi';
5 |
6 | export type UseInfiniteEventsParams = {
7 | time_filter?: TimeFilter;
8 | start_date?: string;
9 | end_date?: string;
10 | registration_policy?: string | null;
11 | event_scope?: string | null;
12 | event_type?: string | null;
13 | event_status?: string | null;
14 | community_id?: number | null;
15 | creator_sub?: string | null;
16 | keyword?: string;
17 | size?: number;
18 | };
19 |
20 | export function useInfiniteEvents(params: UseInfiniteEventsParams = {}) {
21 | const {
22 | time_filter,
23 | start_date,
24 | end_date,
25 | registration_policy,
26 | event_scope,
27 | event_type,
28 | event_status = "approved",
29 | community_id,
30 | creator_sub,
31 | keyword = "",
32 | size = 12,
33 | } = params;
34 |
35 | const infiniteScrollReturn = useInfiniteScroll({
36 | queryKey: ["campusCurrent", "events"],
37 | apiEndpoint: `/${Routes.EVENTS}`,
38 | size,
39 | keyword,
40 | additionalParams: {
41 | time_filter,
42 | start_date,
43 | end_date,
44 | registration_policy,
45 | event_scope,
46 | event_type,
47 | event_status,
48 | community_id,
49 | creator_sub,
50 | },
51 | });
52 |
53 | return useInfiniteScrollWithWindow(infiniteScrollReturn);
54 | }
55 |
--------------------------------------------------------------------------------
/backend/modules/bot/routes/user/private/messages/start_deeplink.py:
--------------------------------------------------------------------------------
1 | from typing import Callable
2 |
3 | from aiogram import Router
4 | from aiogram.filters import CommandObject, CommandStart
5 | from aiogram.types import Message
6 | from aiogram.utils.deep_linking import decode_payload
7 | from google.cloud import storage
8 | from sqlalchemy.ext.asyncio import AsyncSession
9 |
10 | from backend.modules.bot.cruds import (
11 | check_existance_by_sub,
12 | check_user_by_telegram_id,
13 | get_telegram_id,
14 | )
15 | from backend.modules.bot.filters import EncodedDeepLinkFilter
16 | from backend.modules.bot.keyboards.kb import kb_confirmation, user_profile_button
17 | from backend.modules.bot.utils.google_bucket import generate_download_url
18 |
19 | router = Router()
20 |
21 |
22 | @router.message(CommandStart(deep_link=True))
23 | async def user_start_link(
24 | m: Message,
25 | command: CommandObject,
26 | db_session: AsyncSession,
27 | _: Callable[[str], str],
28 | ):
29 | args = command.args
30 | payload: str = decode_payload(args)
31 | sub, confirmation_number = payload.split("&")
32 |
33 | if not await check_existance_by_sub(session=db_session, sub=sub):
34 | return await m.answer(_("Некорректная ссылка"))
35 |
36 | if not await check_user_by_telegram_id(session=db_session, user_id=m.from_user.id):
37 | return await m.answer(
38 | _("Отлично, теперь выбери верный смайлик!"),
39 | reply_markup=kb_confirmation(sub=sub, confirmation_number=confirmation_number),
40 | )
41 | return await m.answer(_("Ваш телеграм аккаунт уже привязан!"))
42 |
--------------------------------------------------------------------------------
/backend/modules/bot/bot.py:
--------------------------------------------------------------------------------
1 | from typing import Annotated
2 |
3 | from aiogram.types import Update
4 | from aiogram.utils.deep_linking import create_start_link
5 | from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
6 | from sqlalchemy.ext.asyncio import AsyncSession
7 |
8 | from backend.common.dependencies import get_creds_or_401, get_db_session
9 | from backend.core.configs.config import config
10 |
11 | web_router = APIRouter(tags=["Bot Routes"])
12 |
13 |
14 | @web_router.post("/webhook")
15 | async def webhook(request: Request) -> Response:
16 | received_token = request.headers.get("X-Telegram-Bot-Api-Secret-Token")
17 | if received_token != config.TG_WEBHOOK_SECRET_TOKEN:
18 | return Response(status_code=status.HTTP_403_FORBIDDEN)
19 |
20 | bot = request.app.state.bot
21 | dp = request.app.state.dp
22 | update = Update.model_validate(await request.json(), context={"bot": bot})
23 | await dp.feed_update(bot, update)
24 | return Response(status_code=status.HTTP_200_OK)
25 |
26 |
27 | @web_router.post("/contact/{product_id}")
28 | async def contact(
29 | request: Request,
30 | product_id: int,
31 | user: Annotated[dict, Depends(get_creds_or_401)],
32 | db_session: AsyncSession = Depends(get_db_session),
33 | ) -> str:
34 | product: Product | None = await find_product(db_session, int(product_id))
35 | if product:
36 | link: str = await create_start_link(
37 | request.app.state.bot, f"contact&{product_id}", encode=True
38 | )
39 | return link
40 | raise HTTPException(status_code=404, detail="Does not exist!")
41 |
--------------------------------------------------------------------------------
/frontend/src/components/atoms/avatar.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
3 |
4 | import { cn } from "@/utils/utils"
5 |
6 | const Avatar = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 | ))
19 | Avatar.displayName = AvatarPrimitive.Root.displayName
20 |
21 | const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
30 | ))
31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
32 |
33 | const AvatarFallback = React.forwardRef<
34 | React.ElementRef,
35 | React.ComponentPropsWithoutRef
36 | >(({ className, ...props }, ref) => (
37 |
45 | ))
46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
47 |
48 | export { Avatar, AvatarImage, AvatarFallback }
49 |
--------------------------------------------------------------------------------