Inquiry closing

This commit is contained in:
Minecon724 2025-04-02 18:40:57 +02:00
commit 2657849748
Signed by: Minecon724
GPG key ID: A02E6E67AB961189
11 changed files with 351 additions and 25 deletions

View file

@ -15,7 +15,7 @@ AnonChat was created using "vibe coding" - a programming approach where develope
Rest assured though, I know what I'm (or the AI is) doing. Here's what would happen if I didn't:
1. [my saas was built with Cursor, zero hand written code \
AI is no longer just an assistant, its also the builder \
AI is no longer just an assistant, it's also the builder \
Now, you can continue to whine about it or start building.](https://xcancel.com/leojr94_/status/1900767509621674109)
2. [random thing are happening, maxed out usage on api keys, people bypassing the subscription, creating random shit on db \
there are just some weird ppl out there](https://xcancel.com/leojr94_/status/1901560276488511759)
@ -83,6 +83,49 @@ This project uses Poetry for dependency management.
- Run tests: `poetry run pytest`
- Run the application: `poetry run start`
### Database Migrations
AnonChat includes a custom database migration system to handle schema changes. When you make changes to the database models, you should create a migration script to apply these changes to existing databases.
#### Running Migrations
- Run all pending migrations: `poetry run flask --app src/anonchat run-migrations`
- The migrations are also automatically run when using the `init-db` command or when starting the application with the entrypoint script.
#### Creating New Migrations
To create a new migration:
1. Create a new Python file in the `src/anonchat/migrations` directory with a descriptive name (e.g., `add_new_column.py`)
2. Implement a `run_migration(db)` function that performs the necessary schema changes
3. The migration script should be idempotent (safe to run multiple times)
Example migration script:
```python
from sqlalchemy import inspect
from sqlalchemy.sql import text
from flask import current_app
def run_migration(db):
"""Add a new column to a table."""
# Check if the column already exists
inspector = inspect(db.engine)
columns = [col['name'] for col in inspector.get_columns('your_table')]
# Only apply changes if needed
if 'your_new_column' not in columns:
current_app.logger.info("Adding new column to table")
with db.engine.connect() as conn:
conn.execute(text("ALTER TABLE your_table ADD COLUMN your_new_column TEXT"))
conn.commit()
return True
return False # Return True if changes were made, False otherwise
```
Migrations are run in alphabetical order, so you may want to prefix migration filenames with a timestamp or sequence number for more complex projects.
## Admin Authentication
AnonChat includes a secure admin authentication system that protects administrative routes and functions. This ensures that only authorized users can access the admin dashboard, manage inquiries, and configure system settings.
@ -147,14 +190,7 @@ The following security enhancements are planned for future releases:
- Implement proper PKCE flow for added security
- Support for custom OAuth providers for enterprise deployments
- Add multi-factor authentication options
### Inquiry Management
- [ ] Add "Close Inquiry" functionality
- Mark inquiries as closed without immediate deletion
- Automatically delete closed inquiries after 2 days
- Allow reopening inquiries before deletion occurs
- Provide visual indicators for closed inquiries in admin interface
### Read-Only Links
- [ ] Implement read-only sharing links for inquiries
- Generate unique, cryptographically secure sharing links

View file

@ -6,6 +6,11 @@ echo "Running database initialization..."
poetry run flask --app src/anonchat init-db
echo "Database initialization complete."
# Run database migrations to ensure schema is up to date
echo "Running database migrations..."
poetry run flask --app src/anonchat run-migrations
echo "Database migrations complete."
# Execute the command passed as arguments (CMD in Dockerfile)
echo "Executing command: $@"
exec "$@"

View file

@ -19,7 +19,8 @@ dependencies = [
"psycopg2-binary (>=2.9.9,<3.0.0)",
"redis (>=5.0.0,<6.0.0)",
"flask-session (>=0.5.0,<1.0.0)",
"argon2-cffi (>=23.0.0,<24.0.0)"
"argon2-cffi (>=23.0.0,<24.0.0)",
"flask-apscheduler (>=1.13.1,<2.0.0)"
]
[tool.poetry]

View file

@ -10,6 +10,7 @@ from flask_session import Session
import redis
from werkzeug.middleware.proxy_fix import ProxyFix
from argon2 import PasswordHasher
from flask_apscheduler import APScheduler
# Load environment variables from .env file
load_dotenv()
@ -46,6 +47,10 @@ elif app.config['SESSION_TYPE'] == 'filesystem':
app.config['SESSION_FILE_MODE'] = os.environ.get('SESSION_FILE_MODE', 384)
app.config['SESSION_KEY_PREFIX'] = 'anonchat_session:'
# Scheduler configuration
app.config['SCHEDULER_API_ENABLED'] = False
app.config['SCHEDULER_TIMEZONE'] = 'UTC'
if app.config['BEHIND_PROXY']:
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=2)
@ -69,6 +74,10 @@ limiter = Limiter(get_remote_address, app=app)
# Initialize database
db = SQLAlchemy(app)
# Initialize scheduler
scheduler = APScheduler()
scheduler.init_app(app)
# Import models
from . import models
# Explicitly import all models to ensure they are registered with SQLAlchemy
@ -104,6 +113,37 @@ def ratelimit_handler(e):
# Import routes
from . import routes
# Setup scheduler jobs
def setup_scheduler_jobs():
# Import the function from tasks.py
from .tasks import check_and_delete_expired_inquiries
# Schedule the job to run every 6 hours
scheduler.add_job(
id='check_and_delete_expired_inquiries',
func=check_and_delete_expired_inquiries,
trigger='interval',
hours=6
)
# Start the scheduler
scheduler.start()
# Start the scheduler when the app is initialized
setup_scheduler_jobs()
# Flask CLI command to run migrations
@app.cli.command("run-migrations")
def run_migrations_command():
"""Run database migrations to update schema."""
with app.app_context():
# Import migrations module here to avoid circular imports
from .migrations import run_migrations
print("Running database migrations...")
migrations_run = run_migrations(db)
print(f"Database migrations completed: {migrations_run} migrations applied.")
# Flask CLI command to initialize the database
@app.cli.command("init-db")
def init_db_command():
@ -113,6 +153,13 @@ def init_db_command():
with app.app_context():
print("Creating database tables...")
db.create_all() # Should now create all tables including Inquiry
# Run migrations after creating tables
# This ensures any new columns added to existing tables are properly added
print("Running database migrations...")
from .migrations import run_migrations
run_migrations(db)
print("Initializing default settings...")
# Initialize settings if they don't exist
# Note: Models already imported above

View file

@ -0,0 +1,31 @@
from flask import current_app
from importlib import import_module
import os
import pkgutil
def run_migrations(db):
"""Run all migrations in the migrations directory"""
current_app.logger.info("Starting database migrations")
migrations_run = 0
# Get the list of all migration modules in this package
# This will look for all Python modules in the migrations directory
migrations_pkg = __name__
for _, name, ispkg in pkgutil.iter_modules([os.path.dirname(__file__)]):
if name.startswith('_') or ispkg:
continue
# Import the migration module
current_app.logger.info(f"Importing migration: {name}")
migration_module = import_module(f"{migrations_pkg}.{name}")
# Run the migration if it has a run_migration function
if hasattr(migration_module, 'run_migration'):
current_app.logger.info(f"Running migration: {name}")
if migration_module.run_migration(db):
migrations_run += 1
else:
current_app.logger.warning(f"Migration {name} has no run_migration function")
current_app.logger.info(f"Database migrations completed. {migrations_run} migrations applied.")
return migrations_run

View file

@ -0,0 +1,43 @@
from sqlalchemy import Column, Boolean, DateTime, inspect
from sqlalchemy.sql import text
from flask import current_app
def run_migration(db):
"""Add is_closed and closing_timestamp columns to the Inquiry table if they don't exist."""
current_app.logger.info("Running migration: add_closed_status")
# Check if the columns already exist
inspector = inspect(db.engine)
columns = [col['name'] for col in inspector.get_columns('inquiry')]
# Track if we need to commit changes
changes_made = False
# Add is_closed column if it doesn't exist
if 'is_closed' not in columns:
current_app.logger.info("Adding is_closed column to inquiry table")
with db.engine.connect() as conn:
conn.execute(text("ALTER TABLE inquiry ADD COLUMN is_closed BOOLEAN DEFAULT FALSE"))
# We need to commit within the connection context for some database types
conn.commit()
changes_made = True
else:
current_app.logger.info("is_closed column already exists in inquiry table")
# Add closing_timestamp column if it doesn't exist
if 'closing_timestamp' not in columns:
current_app.logger.info("Adding closing_timestamp column to inquiry table")
with db.engine.connect() as conn:
conn.execute(text("ALTER TABLE inquiry ADD COLUMN closing_timestamp TIMESTAMP"))
# We need to commit within the connection context for some database types
conn.commit()
changes_made = True
else:
current_app.logger.info("closing_timestamp column already exists in inquiry table")
if changes_made:
current_app.logger.info("Migration completed successfully")
else:
current_app.logger.info("No changes needed, all columns already exist")
return changes_made

View file

@ -4,6 +4,7 @@ import secrets
import argon2
from argon2 import PasswordHasher
from . import password_hasher
from datetime import datetime, timedelta
class Message(db.Model):
inquiry_id = db.Column(db.String(16), db.ForeignKey('inquiry.id'), nullable=False, primary_key=True)
@ -29,6 +30,33 @@ class Inquiry(db.Model):
messages = db.relationship('Message', backref='inquiry', lazy=True,
cascade='all, delete-orphan',
order_by='Message.message_number')
is_closed = db.Column(db.Boolean, default=False)
closing_timestamp = db.Column(db.DateTime, nullable=True)
def close(self):
"""Mark an inquiry as closed"""
self.is_closed = True
self.closing_timestamp = datetime.utcnow()
def reopen(self):
"""Reopen a closed inquiry"""
self.is_closed = False
self.closing_timestamp = None
@staticmethod
def get_expired_inquiries(days=2):
"""Get inquiries that have been closed for more than the specified days"""
expiry_date = datetime.utcnow() - timedelta(days=days)
return Inquiry.query.filter(
Inquiry.is_closed == True,
Inquiry.closing_timestamp <= expiry_date
).all()
def get_deletion_date(self):
"""Get the deletion date for a closed inquiry"""
if self.is_closed:
return self.closing_timestamp + timedelta(days=2)
return None
class Settings(db.Model):
id = db.Column(db.Integer, primary_key=True)

View file

@ -8,7 +8,7 @@ import urllib.error
import hmac
import hashlib
import json
from datetime import datetime
from datetime import datetime, timedelta
def is_admin():
return 'admin_authenticated' in session and session['admin_authenticated']
@ -105,27 +105,33 @@ def inquiry(inquiry_id):
is_admin = 'admin_authenticated' in session and session['admin_authenticated']
if request.method == 'POST':
if is_admin:
message_content = request.form.get('admin_response')
is_admin_message = True
else:
message_content = request.form.get('message')
is_admin_message = False
# Handle closed inquiry differently for admin vs non-admin
if inquiry.is_closed:
# Auto-reopen the inquiry when admin responds
inquiry.reopen()
db.session.commit()
# Send webhook for reopened inquiry
send_webhook('inquiry_reopened', {
'inquiry_id': inquiry_id
})
message_content = request.form.get('message')
if message_content and message_content.strip():
message = Message(
content=message_content,
inquiry_id=inquiry_id,
is_admin=is_admin_message
is_admin=is_admin
)
db.session.add(message)
db.session.commit()
if not is_admin_message:
if not is_admin:
send_webhook('new_message', {
'inquiry_id': inquiry_id,
'message': message_content,
'is_admin': is_admin_message
'is_admin': is_admin
})
return redirect(url_for('inquiry', inquiry_id=inquiry_id))
@ -323,4 +329,50 @@ def get_inquiry_messages(inquiry_id):
return jsonify({
'inquiry_id': inquiry_id,
'messages': messages_json
})
})
@app.route('/inquiry/<inquiry_id>/close', methods=['POST'])
@admin_required
def close_inquiry(inquiry_id):
inquiry = Inquiry.query.get_or_404(inquiry_id)
# Only close if not already closed
if not inquiry.is_closed:
inquiry.close()
db.session.commit()
# Send webhook for closed inquiry
send_webhook('inquiry_closed', {
'inquiry_id': inquiry_id
})
# Redirect back to the inquiry or admin dashboard based on referrer
referrer = request.referrer
if referrer and 'admin/dashboard' in referrer:
return redirect(url_for('admin_dashboard'))
else:
return redirect(url_for('inquiry', inquiry_id=inquiry_id))
@app.route('/inquiry/<inquiry_id>/reopen', methods=['POST'])
@admin_required
def reopen_inquiry(inquiry_id):
inquiry = Inquiry.query.get_or_404(inquiry_id)
# Only reopen if currently closed
if inquiry.is_closed:
inquiry.reopen()
db.session.commit()
# Send webhook for reopened inquiry
send_webhook('inquiry_reopened', {
'inquiry_id': inquiry_id
})
flash('Inquiry reopened successfully.')
# Redirect back to the inquiry or admin dashboard based on referrer
referrer = request.referrer
if referrer and 'admin/dashboard' in referrer:
return redirect(url_for('admin_dashboard'))
else:
return redirect(url_for('inquiry', inquiry_id=inquiry_id))

29
src/anonchat/tasks.py Normal file
View file

@ -0,0 +1,29 @@
from flask import current_app
from . import db
from .models import Inquiry
from datetime import datetime
def check_and_delete_expired_inquiries():
"""Check and delete inquiries that have been closed for more than 2 days"""
# Get app context from current_app
app = current_app._get_current_object()
with app.app_context():
app.logger.info("Running scheduled task: check_and_delete_expired_inquiries")
expired_inquiries = Inquiry.get_expired_inquiries(days=2)
deleted_count = 0
for inquiry in expired_inquiries:
# Delete the inquiry
db.session.delete(inquiry)
deleted_count += 1
# Send webhook for automatically deleted inquiry
from .routes import send_webhook
send_webhook('inquiry_auto_deleted', {
'inquiry_id': inquiry.id
})
if deleted_count > 0:
db.session.commit()
app.logger.info(f"Automatically deleted {deleted_count} expired closed inquiries")

View file

@ -16,6 +16,7 @@
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Messages</th>
<th>Latest Message</th>
<th>Time</th>
@ -24,8 +25,20 @@
</thead>
<tbody>
{% for item in inquiries_with_data %}
<tr>
<tr {% if item.inquiry.is_closed %}style="background-color: #f8f9fa; opacity: 0.8;"{% endif %}>
<td>{{ item.inquiry.id }}</td>
<td>
{% if item.inquiry.is_closed %}
<span style="color: #6c757d; font-size: 0.8rem; padding: 0.2rem 0.4rem; background-color: #e9ecef; border-radius: 0.25rem;">CLOSED</span>
{% if item.inquiry.closing_timestamp %}
<span style="display: block; font-size: 0.7rem; margin-top: 0.2rem;">
Closed on {{ item.inquiry.closing_timestamp.strftime('%Y-%m-%d %H:%M') }}
</span>
{% endif %}
{% else %}
<span style="color: #28a745; font-size: 0.8rem; padding: 0.2rem 0.4rem; background-color: #e9ecef; border-radius: 0.25rem;">OPEN</span>
{% endif %}
</td>
<td>{{ item.message_count }}</td>
<td>
{% if item.latest_message %}
@ -43,6 +56,19 @@
</td>
<td>
<a href="{{ url_for('inquiry', inquiry_id=item.inquiry.id) }}">View/Respond</a>
{% if item.inquiry.is_closed %}
<form method="POST" action="{{ url_for('reopen_inquiry', inquiry_id=item.inquiry.id) }}" style="display: inline; margin-left: 0.5rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" style="background-color: #28a745; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 0.25rem; cursor: pointer; font-size: 0.8rem;">Reopen</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('close_inquiry', inquiry_id=item.inquiry.id) }}" style="display: inline; margin-left: 0.5rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" style="background-color: #6c757d; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 0.25rem; cursor: pointer; font-size: 0.8rem;">Close</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('delete_inquiry', inquiry_id=item.inquiry.id) }}" style="display: inline; margin-left: 0.5rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" onclick="return confirm('Are you sure you want to delete this inquiry? This action cannot be undone.')" style="background-color: #dc3545; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 0.25rem; cursor: pointer; font-size: 0.8rem;">Delete</button>

View file

@ -6,7 +6,11 @@
{% block content %}
<div class="inquiry-details" id="inquiry-details" data-inquiry-id="{{ inquiry.id }}" data-last-message-number="{{ messages[-1].message_number if messages else 0 }}">
<h2>Inquiry #{{ inquiry.id[:6] }}</h2>
<h2>Inquiry #{{ inquiry.id[:6] }}
{% if inquiry.is_closed %}
<span style="color: #6c757d; font-size: 0.9rem; padding: 0.3rem 0.6rem; background-color: #e9ecef; border-radius: 0.25rem; margin-left: 0.5rem;">CLOSED</span>
{% endif %}
</h2>
{% if is_admin %}
{{ admin_header() }}
@ -19,7 +23,25 @@
<p class="warning"><strong>Important:</strong> Do not share this link with anyone else, as anyone you share it with could access this chat.</p>
{% endif %}
{% if inquiry.is_closed %}
<div style="margin-bottom: 1rem; padding: 0.75rem; background-color: #3a3a3a; border-radius: 0.25rem; border-left: 4px solid #8c8c8c; color: #f0f0f0;">
<strong>This inquiry is closed.</strong>
{% if inquiry.closing_timestamp %}It will be deleted on {{ inquiry.get_deletion_date().strftime('%Y-%m-%d %H:%M') }} UTC.{% endif %}
</div>
{% endif %}
<div style="margin-bottom: 1rem;">
{% if inquiry.is_closed %}
<form method="POST" action="{{ url_for('reopen_inquiry', inquiry_id=inquiry.id) }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" style="background-color: #28a745; color: white; border: none; padding: 0.375rem 0.75rem; border-radius: 0.25rem; cursor: pointer; margin-right: 0.5rem;">Reopen Inquiry</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('close_inquiry', inquiry_id=inquiry.id) }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" style="background-color: #6c757d; color: white; border: none; padding: 0.375rem 0.75rem; border-radius: 0.25rem; cursor: pointer; margin-right: 0.5rem;">Close Inquiry</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('delete_inquiry', inquiry_id=inquiry.id) }}" style="display: inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" onclick="return confirm('Are you sure you want to delete this inquiry? This action cannot be undone.')" style="background-color: #dc3545; color: white; border: none; padding: 0.375rem 0.75rem; border-radius: 0.25rem; cursor: pointer;">Delete Inquiry</button>
@ -50,9 +72,15 @@
<form method="POST" action="{{ url_for('inquiry', inquiry_id=inquiry.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<textarea name="{% if is_admin %}admin_response{% else %}message{% endif %}" rows="4" placeholder="Type your message..." required></textarea>
<textarea name="message" rows="4" placeholder="Type your message..." required></textarea>
</div>
<button type="submit">Send Message</button>
<button type="submit">
{%if inquiry.is_closed %}
Send Message and Reopen Inquiry
{% else %}
Send Message
{% endif %}
</button>
</form>
</div>
</div>